ACTF2026-WriteUp
Web
12307
Book your train ticket to the FLAG…… Now!
这题本质上是一条比较长的业务利用链,目标是把受限的“企业对账收据渲染”流程推进到内部打印链,并借打印链执行白名单程序读取 /flag
最终利用点有两个核心:
station_portal的/api/station-office/fares/reprice存在可控ORDER BY表达式,能够做布尔盲注,进而盲出station_claim_artifacts.claim_saltreceipt_signer在校验carrierSeal.payload时使用了“同名键首次生效”的 JSON 解析,而在后续生成print_plan时使用的是标准 JSON 解析“同名键最后生效”。因此可以构造重复键,让校验看到安全值,渲染阶段拿到危险值
之后再配合站点 notice 导入、desk adjustment、企业导入、waitlist websocket 等几个业务接口,把 signer 和 settlement worker 的全部校验条件补齐,即可在对账结果中拿到 /flag 的 base64 输出
服务分析
票务主流程
services/ticketing_api/app.py
POST /api/orders会创建订单- 对于
G7608的business座位,初始库存是 0,因此下单会直接进入waitlisted - waitlist 订单会调用
issue_claim_artifact(),在station_claim_artifacts里写入:order_idticket_noclaim_saltclaim_digest
这正是后续 desk 调整流程所依赖的数据
claimProof 校验
services/station_portal/app.py
POST /api/station-office/tickets/adjust 会校验:
ticketNo必须存在- 数据库里必须有未过期的
station_claim_artifacts - 传入的
claimProof必须等于:
CP-{claim_salt}-{sha256(order_id|train_id|station_code|ticket_no|claim_salt)[:12]} |
所以只要拿到 claim_salt,就能伪造合法 claimProof
SQL 注入点
同文件中的 reprice_fare():
scope = fare_scope_expression(data.get("tariffScope", "ticket")) |
而 fare_scope_expression() 中:
if scope.get("mode") == "legacy-rank": |
也就是说,只要提交:
{ |
就能把任意表达式拼进 ORDER BY
接口返回值会根据查询第一行的 ticket_no 前缀不同,返回不同 bucket:
T-BJP-...->north-window- 其他 ->
local-window
这就形成了布尔盲注信道
利用链
一、创建 waitlist 订单
目标是得到一个新的 HGH / G7608 / business waitlist 订单
利用前端同款身份续接流程:
POST /api/mobile/identity/continuePOST /api/mobile/orders/holdPOST /api/mobile/orders
这样能得到:
passenger_sessionwaitlist_session- 一个新的 waitlist
orderId
二、盲注出 claim_salt
利用 /api/desk/fares/reprice 的 legacy-rank 注入点
布尔表达式可以写成:
(case when (<condition>) then (station_code='HGH') else (station_code='BJP') end) desc |
如果 <condition> 为真,那么排序会优先把 HGH 的票排前面,返回 local-window
否则会优先把 BJP 的票排前面,返回 north-window
然后就可以逐位盲注出 claim_salt:
substr((select claim_salt from station_claim_artifacts where order_id='...'),pos,1) |
盲注出的新订单 salt 为:
RQDWRNFDP |
于是可以计算:
claim_digest = sha256(order_id|G7608|HGH|T-HGH-7608-019|RQDWRNFDP) |
三、打开受信站点条件
receipt_signer 要求多个上下文条件同时满足,关键包括:
- station profile 已打开
batch_open - route 命中受信 lane
- partner jwks 已写入 redis
- waitlist 已 sample
- layout entitlement 已下发
- passenger continuation 存在
- ledger channel 存在
这些条件分几步补齐
通过 notice + import 写入 lane/jwks/board profile
services/station_import/app.py 中的 compile_notice_feed() 会读取最近的 station_notices.proxy_hint,并在满足条件后写入:
rail:interline:lane:{station}rail:board:profile:{station}rail:partner:jwks:{station}
因此先发一个 notice:
X-Desk-Lane: delta-window-27 |
然后调用:
POST /api/desk/imports/health |
即可把受信 lane、board profile、partner jwks 写进 redis
用合法 claimProof 触发 desk rule
POST /api/desk/tickets/adjust
传入:
ticketNo = T-HGH-7608-019claimProof = CP-RQDWRNFDP-48958b5a1cf5memo为 JSON 字符串
示例 memo:
{ |
随后调用:
POST /api/desk/imports/health |
这会触发 apply_adjustment_rules(),完成:
waitlist_entries.sampled = 1station_profiles.batch_open = 1station_profiles.renderer_profile = folio-grid-27station_profiles.signer_route = delta-window-27- 写入
tariff_exception_claims
激活 layout entitlement
调用:
POST /api/corporate/imports/relay |
会进入 activate_layout_claim(),完成:
- 把对应 layout cell 改成
service-device - 写入
rail:layout:entitlement:{orderId} = HGH
打出 fulfillment epoch
还需要:
rail:fulfillment:epoch:{orderId} = boarding |
可通过:
POST /api/corporate/imports/relay |
注意这个 key 只有很短的过期时间,后面需要贴着 schedule 再打一遍
四、拿到 ledgerRef
services/waitlist_push/server.js
通过 websocket /api/connect/boarding 可以完成:
boarding.helloboarding.bindboarding.confirm
成功后 redis 中会写入:
rail:ledger:channel:{orderId}
同时接口会返回:
ledgerRefboardingNonce
其中 receipt_signer 校验 carrierSeal 时必须匹配 ledgerRef
五、利用重复 JSON 键绕过 signer 校验
services/receipt_signer/app.py
关键逻辑:
public_view = json.loads(payload_text, object_pairs_hook=first_wins_object) |
这里:
public_view遇到重复键时保留第一次render_view遇到重复键时保留最后一次
而后续:
- 校验阶段用
public_view - 生成
print_plan时用render_view
所以 payload 可以构造为:
{ |
这样:
- signer 校验看到的是
printProfile = counter-copyprinter = thermal-standard
- settlement worker 真正渲染时拿到的是
printProfile = clearing-batchprinter = line-printerdriverProgram = /usr/bin/base64driverArgument = /flag
利用 /usr/bin/base64 读取 flag
Dockerfile 里有:
profile-delta-closeout -> acceptedPrograms = ["/usr/bin/base64"] |
而且 /usr/bin/base64 被设置了 SUID:
chmod 4755 /usr/bin/base64 |
因此,打印链最终会以高权限读取 /flag 并把内容 base64 输出
生成收据并调度渲染
先创建 defer batch:
POST /api/corporate/reconciliation |
再调用:
POST /api/corporate/receipts/prepare |
成功后会拿到 signed
最后在 schedule 前立刻重新打一遍:
adapter = fulfillment-monitor |
因为 fulfillment epoch 很快过期
然后:
POST /api/corporate/settlement/schedule |
最终返回:
Reconciliation ORMG42V34OQ waitlisted QUNURnt3SHlfYXIxX3kwdV9zbzBPMG8wT28wb19GYXMxPz8/Pz9fQzJDZnc2cnlEOTR9 |
base64 解码得到 flag
ACTF{wHy_ar1_y0u_so0O0o0Oo0o_Fas1?????_C2Cfw6ryD94} |
这题的关键漏洞点可以总结为:
ORDER BY表达式注入导致布尔盲注- 业务对象
claimProof可被伪造,从而打通 desk 调整流程 - 通过 notice feed 和 import relay 可向 redis 写入多个“受信上下文”状态
carrierSeal的重复 JSON 键前后解析不一致,造成校验视图与执行视图分离- 打印链允许执行白名单程序,而
base64恰好可读取/flag
import base64 |
Misc
special day
Today is ACTF, and it is also Mother’s Day in China.
The competition matters, but don’t forget to send your mom a simple blessing.
Even one short sentence is enough.
Use _ to join the words, remove punctuation, and wrap it with ACTF{}.
SGFwcHkgTW90aGVyJ3MgRGF5LCBNb20h,解 Base64 并按要求提交
ZJUAM Just Uses Awful Math
有黑调的迪克把我的浙江大学统一身份认证登录过程抓包了,黑调的迪克怎么这么坏啊
http 明文传输,RSA 采用了小公钥进行加密
from Crypto.Util.number import * |
∀gent
I vibe coding a website that contains an agent.
All things seem awesome except for its front end.
It sounds great though, agent coding for agent.
But both agent coding and human coding are build from small to big, maybe the same for this CTF problem.
核心入口在server.js
可以很快注意到两点:
src/store.js里存在固定写入的 fake flag,会在会话消息中展示- 真正危险的逻辑在
/api/projects/:id/agent/override这条链路
相关调用关系:
server.jsPOST /api/projects/:id/agent/overridesrc/job-runner.jsrunOverrideJobsrc/path-builder.jsbuildPropertyPathsrc/tool-registry.jsconfig.diff/policy.evaluatesrc/config-engine.jsapplyChanges
fake flag 在 src/store.js 硬编码:
content: "ACTF{WuYan_1s_4_b19_Turt13_N07_7h3_F1n41_Fl4g}" |
src/path-builder.js 中:
function sanitizeSegment(input, fallback) { |
这里的 scope/environment/section/field 几乎没有过滤,只是简单 trim,随后直接拼进属性路径
src/config-engine.js 会调用 vendored 的 candidate-yaml-update-action:
const changedFile = upstream.processFile( |
而 candidate-yaml-update-action 内部最终使用:
jsonpath.value(copy, jsonPath, value); |
这意味着我们控制的“配置路径”,实际上会被当作 JSONPath 来解析
因为属性路径可控,我们可以写入:
agentProfile.scopes.release.environments.staging.image.constructor.prototype.policy |
这会把对象原型上的 policy 属性污染掉
随后在 src/tool-registry.js 的 evaluatePolicy 中:
const workspacePolicy = repoFacts.policy || {}; |
repoFacts 本身没有 policy,但由于原型链被污染,repoFacts.policy 会从 Object.prototype.policy 取到我们注入的值
src/tool-registry.js 中:
if (verdict.blockedCount > 0) { |
如果把污染的 bindingProfile 设为 compat,就会进入兼容解释器分支
而 executeFormulaExpression 里直接有:
const result = eval(`(function(${argNames.join(',')}) { return (${expression}); })`)( |
这样就可以让我们控制的 formula 作为 JavaScript 执行
完整利用链如下:
- 向
/api/projects/workspace-main/agent/override发送请求 - 通过
field=constructor.prototype.policy污染Object.prototype.policy - 注入:
bindingProfile=compatselectorProfile=linkedresultProfile=decimalexposeDebugContext=trueformula=<恶意 JS 表达式>
- 第二次触发同一路由时,服务端会在
policy.evaluate中读取污染后的policy - 兼容模式下执行我们控制的
formula - 读取
/flag - 结果出现在返回 JSON 的
job.result.evaluation.formulaResult
import json |
ezssh*
这题有点抽象,疑似公共靶机被拉屎了……