深入解析SSH CRC32攻击检测漏洞的利用之道

本文详细分析了OpenSSH中经典的CRC32补偿攻击检测漏洞(CAN-2001-0144),从漏洞原理到实际利用,展示了如何在现代系统中复现这一历史性安全漏洞,并探讨了多种可能的利用路径。

介绍

如果你刚刚接触这个系列,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。

这个"crc32"检测器存在一个独特的内存破坏漏洞,可能导致任意代码执行。直到6月份,TESO Security才发布了关于他们编写的漏洞利用程序泄露的声明,这表明在6月之前没有可靠的公开漏洞利用程序。

动手尝试

在海上没有互联网的情况下,尝试构建20年前的软件是一场噩梦。当我们的一些团队成员致力于此时,我们将漏洞移植到了一个独立的main.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
/* 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。

变量n在早期声明为16位值(static u_int16_t),而l是32位(u_int32_t)。因此,如果l大于0xffff,在n = l时可能发生整数溢出。

for循环中的位运算:

1
2
for (l = n; l < HASH_FACTOR(len / SSH_BLOCKSIZE); l = l << 2)
	;

这个神秘的循环是我们设置l的唯一机会。它最初将l设置为n。每次循环运行时,l左移2位(l « 2),这实际上每次迭代都将l乘以4。

我们知道l最初是0x1000,经过一次循环后变为0x4000,再次循环变为0x10000。这个0x10000值转换为u_int16_t时会溢出并结果为0。因此所有可能的n值是0x1000、0x4000和0。

崩溃分析

崩溃发生是因为检查h[0x41414141] != HASH_UNUSED时访问了无效内存:

 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] != 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; // 任意写入!!!
}

如果h[i]是一个可读偏移量?经过一些检查后,我们会到达h[i] = j的位置。注意j是循环中的迭代次数,我们可以通过缓冲区长度控制它。i是我们的0x41414141,我们也可以控制它。因此我们最终在循环中获得了一个写-什么-在哪里的原语。

攻击真实系统

此时我们有了一个正常工作的OpenSSH服务器设置。我们需要通过SSH协议1发送缓冲区。我们找不到能够与这种过时的损坏协议配合使用的SSH Python客户端。预期的解决方案是修补OpenSSH加密部分,使其成为简单的套接字连接。

相反,我们修补了源代码附带的OpenSSH客户端。似乎真正的漏洞利用作者可能采取了类似的方法。

利用挑战

理解这个利用原语有很多弱点很重要。h缓冲区是u_int16_t *。在小端系统上,除非设置j的高位,否则无法向(char *)h + 0写入任意值。要能够设置j的所有高位,需要能够循环0x10000次。

此外,如果想将相同的值写入两个位置,必须在不崩溃的情况下两次调用易受攻击的函数。但是一旦导致静态n为0,它将在下次重新进入时保持为0。这将导致l位移动循环无限循环。

所有这些都会影响你选择实现RCE的路径。Trinity的漏洞利用用新的任意字符串覆盖了root密码。这是通过将记录器指向/etc/passwd完成的吗?与shell代码相比,这样做有什么优势?破坏身份验证流程并将"已认证"位从false翻转为true怎么样?能否覆盖内存中的客户端公钥,使RSA指数为0?有这么多有趣的选项可以尝试。

结论

我们的目标是使修补过的OpenSSH崩溃。考虑到可用的时间和资源,我们超出了自己的期望,通过控制使未修补的OpenSSH崩溃。这归功于团队合作和利用过程中的创造性时间节省。在整个过程中有大量的理论构建,帮助我们避免了时间陷阱。最重要的是,我们玩得很开心。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计