WEBPWN+CSU+ORW(OPENFILE)+RECV()



最近在打御网杯,前面打了 ISCC,结果又轮到我分享会了,其实最近打比赛感触蛮大的,你说想体验一把真的是有点…… CTF 不用 AI 吧,且不说不会写的,即使到最后 ak 了也只能吃个低保,用了 AI 吧,又有一种感觉牢无所获,没有丝毫体验感,赛后也是一直在赶 wp 根本没时间复现,其实 ai 打出来之后也不想去复现了,想偷懒看看 ai 的思路就当是过去了,久而久之感觉自己的能力在退化,很多知识不学忘得也很快,算了不再多说了,赶一下分享会的内容:
在这之前,已经学过三道 webpwn 的题目了,其中两道是基于 httpd 服务的,不管怎么说,这类题目在最近都是比较热门的,国赛和其他各类大赛都朝着真实环境去发展。之前一道是 NSSCTF 的 [GHCTF 2025]Fruit_Ninja , 一道是 PolarisCTF 的 httpd。相比之下 PolarisCTF 的 httpd 是这三个当中最难的,即使写过一遍,也很难再去复现。webpwn 的题目就难在陌生的框架以及 exp 中不熟悉的语法,由于 AI 的发展,当前的 PWN 题目已经越来越综合,感觉 emmm 也是走一步是一步吧,暂且保证基础题的分数。
https://idcm-svg.github.io/HFTTC.github.io/2026/05/23/WEB-PWN/%5BGHCTF2025%5DFruitNinja/
https://idcm-svg.github.io/HFTTC.github.io/2026/05/23/PolarisCTF_2026_WEBPWN/httpd/
复现 minihttpd 呢既是复习也是学习,温习半个月之前学习的 httpd 架构,学习这道题目新的利用手法。最重要的是 CISCN 总是连着两年出同类型的题目:
2023 和 2024 连着出了两年 GOPWN(GOPWN 到现在都没学会 T_T),2023 和 2024 也连着出了 Protobuf 脱壳二进制 PWN,记得还连着出了 C++PWN,2027 年会不会嘿嘿 ^_^…… 不过不管出什么题目,都希望自己能够写出来,那么要求就是在打好基础的前提下发散思维,多了解一下前沿的知识。
文章会对该国赛题目做一个详尽的解释,大部分不重要的函数也会讲解,毕竟写题不可模棱两可,不举一反三:
[CISCN 2026]minihttpd

看一下题目保护,这里相当于就是开启了 NX,记得 PolarisCTF 那个开了 Canary 可给我累坏了,继续看 IDA:
忘记 patch 了额,先 patch 一下:

程序分析:
main
MiniHttpd 程序的 Main 函数 是一个非常经典的多线程并发网络服务器的模型。
1 | __int64 __fastcall main(__int64 a1, char **a2, char **a3) |
1 | ::fd = -1; // 全局变量,用于存储服务端的监听 Socket FD(文件描述符) |
程序开始时,设置了全局和局部的网络文件描述符(FD)初始状态为 -1(无效值),并指定了默认端口 9999。
main 函数的主要部分都在 sub_402D2D(); 当中,
剩下的部分没什么,主要是多线程并发网络服务器的主循环。它的核心逻辑是:服务器持续监听端口,每当有一个新的客户端连接进来,就创建一个新子线程去专门处理该客户端的请求,而主线程则继续回去等待下一个连接。
sub_402D88()
1 | __int64 sub_402D88() |
- signal 函数用作信号处理
- seccomp 表明开启了沙箱,算是比较常见的初始化函数,策略转向 ORW 打法

sub_4001DD5
1 | __int64 __fastcall sub_401DD5(uint16_t *a1) |
Linux 服务端 Socket 初始化函数:
创建套接字 (Socket Creation)
1 | len = 16; |
socket(2, 1, 0):这是系统调用socket(AF_INET, SOCK_STREAM, 0)的底层表示。2代表AF_INET,即使用 IPv4 网络协议。1代表SOCK_STREAM,即使用 TCP 面向连接的数据流。0代表使用默认协议。
- 成功时返回一个文件描述符
fd。如果失败返回-1,则调用sub_401FA5,这显然是一个错误处理封装函数(内部大概率调用了perror并exit退出了程序)。
设置套接字选项 (Socket Options)
1 | optval = 1; |
为了让服务器更健壮,程序开启了两个重要的 TCP 选项:
setsockopt(fd, 1, 2, ...):对应SO_REUSEADDR。当服务端意外崩溃或重启时,其占用的端口往往会处于TIME_WAIT状态,短时间内无法再次绑定。开启此选项允许服务器立即重用该端口。setsockopt(fd, 1, 15, ...):对应SO_REUSEPORT。允许多个套接字绑定到同一个 IP 和端口,通常用于多进程/多线程的网络负载均衡。
配置 IP 与端口并绑定 (Bind)
1 | memset(&s, 0, sizeof(s)); |
这部分代码在 C 源码中原本应该是操作 struct sockaddr_in 结构体,被 IDA 反编译成了强转操作:
s.sa_family = 2:等同于sin_family = AF_INET(IPv4)。*(_WORD *)s.sa_data = htons(*a1):等同于sin_port = htons(port)。将传入的端口号(比如前面的 9999)转换为网络字节序(大端序)。*(_DWORD *)&s.sa_data[2] = htonl(0):等同于sin_addr.s_addr = INADDR_ANY(即0.0.0.0)。表示监听服务器上所有的网卡 IP 地址。bind(...):将上述配置的 IP 和端口与我们创建的fd绑定起来。0x10u就是 16 字节,即sockaddr_in结构体的大小。
处理动态端口分配 (Dynamic Port Retrieval)
1 | if ( !*a1 ) |
- 逻辑:如果传入的端口号
*a1是0,操作系统会随机分配一个可用的高位端口给这个程序。 - getsockname(…):如果是随机分配的,程序本身并不知道具体的端口是多少,因此需要调用此函数向操作系统“查询”刚刚分配的端口,并通过
ntohs转换回主机字节序,写回到*a1中。
在第一段 main 函数代码中,明确传入了 v7 = 9999,所以通常情况下这个 if 分支在这里不会被触发。
开启监听
1 | if ( listen(fd, 5) < 0 ) |
listen(fd, 5):正式将这个 Socket 标记为“被动监听”状态。第二个参数5是 backlog(全连接队列长度),表示在程序调用accept把连接取走之前,系统最多允许排队等待的 TCP 连接数是 5 个。- 最后,返回这个配置完毕的监听文件描述符
fd给main函数,也就是赋值给了那个全局变量::fd。
sub_402D2D
1 | __int64 sub_402D2D() |
sub_4027D2 相当于是生成了一个数组结构体,大概长这个样子:
1 | struct Route { |
我们在进行不同的申请的时候,就会解析 path,不同 path 有不同的函数去解析:
1 | /* |
主要的漏洞点在 sub_402A40

这里截图直接展示了两个溢出点,可以去简单分析一下这两个溢出点的漏洞:核心在这几行代码
1 | v11 = (_DWORD)v12 - (_DWORD)a2; |
这里对 v11 和 v10 的值进行了定义:
- v11 就是从字符串开始到 ‘=’ 的长度,但是 dest 的大小只有十个字节,假如构造一个 b’a’*20 + b’=’类型的 payload,就可以轻松地导致 memcpy 在粘贴的过程中导致栈溢出
- v10 = 总长度 - 字符串开始到 ‘=’ 的长度 - 1 ,-1 其实就是去除等于号本身的长度 ,例如我构造一个正常的 payload:setmode=hahaha,那么 v10 就是 ‘hahaha’ 的长度 6,也就是说 v10 的值实质是由等于号后面的字符串长度控制的,那么只要我们加长等于号后的字符串长度也是可以轻松利用 memcpy 进行栈溢出的。
EXP 思路:
开始本地调试,然后本地远程连接本地 127.0.0.1:9999
1 | server = process("./main_patched") |
尝试本地连接服务
1 | for attempt in range(1, 21): |
本地服务常有时序抖动(我也不太清楚,反正大概意思是就是不稳定不一定可以连接上吧,事实也确实是这个样子):

构造 POST /setmode 请求
1 | body = b"setmode=" + payload |
其实就是构造一个 WEBPOST 传参请求包,和我们抓包的内容是一样的,大概构造出如下的 POST 请求包:
1 | POST /setmode HTTP/1.0 |
接下来就该去思考如何在 body 中利用前面所说的两个栈溢出漏洞,当然不能忘记 Seccomp 的存在,只能打 ORW 的。
构造 body/payload
我们选择在 body 部分构造栈溢出,但是当前并不确定缓冲区的长度,cyclic 测一下:

1 | body = b"setmode=" + aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabmaabnaaboaabpaabqaabraabsaabtaabuaabvaabwaabxaabyaabzaacbaaccaacdaaceaacfaacgaachaaciaacjaackaaclaacmaacnaacoaacpaacqaacraacsaactaacuaacvaacwaacxaacyaaczaadbaadcaaddaadeaadfaadgaadhaadiaadjaadkaadlaadmaadnaadoaadpaadqaadraadsaadtaaduaadvaadwaadxaadyaadzaaebaaecaaedaaeeaaefaaegaaehaaeiaaejaaekaaelaaemaaenaaeoaaepaaeqaaeraaesaaetaaeuaaevaaewaaexaaeyaaezaafbaafcaafdaafeaaffaafgaafhaafiaafjaafkaaflaafmaafnaafoaafpaafqaafraafsaaftaafuaafvaafwaafxaafyaafzaagbaagcaagdaageaagfaaggaaghaagiaagjaagkaaglaagmaagnaagoaagpaagqaagraagsaagtaaguaagvaagwaagxaagyaagzaahbaahcaahdaaheaahfaahgaahhaahiaahjaahkaahlaahmaahnaahoaahpaahqaahraahsaahtaahuaahvaahwaahxaahyaahzaaibaaicaaidaaieaaifaaigaaihaaiiaaijaaikaailaaimaainaaioaaipaaiqaairaaisaaitaaiuaaivaaiwaaixaaiyaaizaajbaajcaajdaajeaajfaajgaajhaajiaajjaajkaajlaajmaajnaajoaajpaajqaajraajsaajtaajuaajvaajwaajxaajyaajzaakbaakcaakdaakeaakfaakgaakhaakiaakjaakkaaklaakmaaknaakoaakpaakqaakraaksaaktaakuaakvaakwaakxaakyaakzaalbaalcaaldaaleaalfaalgaalhaaliaaljaalkaallaalmaalnaaloaalpaalqaalraalsaaltaaluaalvaalwaalxaalyaalzaambaamcaamdaameaamfaamgaamhaamiaamjaamkaamlaammaamnaamoaampaamqaamraamsaamtaamuaamvaamwaamxaamyaamzaanbaancaandaaneaanfaangaanhaaniaanjaankaanlaanmaannaanoaanpaanqaanraansaantaanuaanvaanwaanxaanyaanzaaobaaocaaodaaoeaaofaaogaaohaaoiaaojaaokaaolaaomaaonaaooaaopaaoqaaoraaosaaotaaouaaovaaowaaoxaaoyaaozaapbaapcaapdaapeaapfaapgaaphaapiaapjaapkaaplaapmaapnaapoaappaapqaapraapsaaptaapuaapvaapwaapxaapyaapzaaqbaaqcaaqdaaqeaaqfaaqgaaqhaaqiaaqjaaqkaaqlaaqmaaqnaaqoaaqpaaqqaaqraaqsaaqtaaquaaqvaaqwaaqxaaqyaaqzaarbaarcaardaareaarfaargaarhaariaarjaarkaarlaarmaarnaaroaarpaarqaarraarsaartaaruaarvaarwaarxaaryaarzaasbaascaasdaaseaasfaasgaashaasiaasjaaskaaslaasmaasnaasoaaspaasqaasraassaastaasuaasvaaswaasxaasyaaszaatbaatcaatdaateaatfaatgaathaatiaatjaatkaatlaatmaatnaatoaatpaatqaatraatsaattaatuaatvaatwaatxaatyaat |

1 | offset = 1088 |
1 | .text:0000000000402FD0 |
这里主要就是利用 CSU 构造 payload 的过程了:
- 先利用 csu 构造
recv(4, bss, 0x20, 0),在函数调用结束后就会执行 recv 0x20 个字节的内容到 bss 段,此时可以利用 exp 向程序发送信息。在后面继续调用open_file(4, bss),也就是将刚才传入的字符串当做文件名解析名并读取文件内容。假设我们传入 b’flag\x00’ 就可以打开远程的 flag 文件夹读取内容。
发送 payload
1 | io.send(req) |
总 EXP:
1 | from pwn import * |

总结:
整个题目的流程大概就是这个样子,毕竟是跟着复现,再加上之前写过类似的题目,整体是比较流畅的。不过这不代表着自己可以在面对陌生的题目可以独立完整的搓出来 exp,该题目当中 csu 的手法我其实是没有想到的,单独调用 recv 函数再利用 openfile 去读取这种 ORW 的打法也是比较新奇,之间只见过一次 openfile 的利用手法。WEBPWN 的题目还是练习得比较少,需要多去练习,这类题目只会越来越难。
说些什么吧!