Recover EVM Function Selectors from Bytecode Offline with REA
On this page
Short version: rea inspect-evm-interface <file> hex --json (MCP: inspect_evm_interface) reads a local EVM bytecode file and runs the bundled EVMole 0.9.3 on it. You get the function selectors it finds, each with a bytecode offset, a dispatch kind, an inferred argument string and an inferred state mutability. It runs fully offline, with no RPC, explorer or wallet involved, and it does not produce names or Solidity source. Every result is labeled inferred. In 6.4.0 it only runs on Linux x64 with util-linux prlimit available. The reliable way to learn what the output means is to run it first on a contract you compiled yourself, where solc gives you the ground truth.
This guide is for developers, auditors and CTF players who have runtime bytecode and want to know which external functions it dispatches: a contract you deployed whose source got out of sync, a dependency you’re checking before integrating it, or an Ethernaut level. It’s not an “EVM decompiler” in the Dedaub or heimdall sense. It recovers the interface, not the logic, and the guide is upfront about that boundary.
You will learn:
- how to produce runtime bytecode from OpenZeppelin’s ERC-20 with solc
- how to run
inspect-evm-interfaceand read every field it returns - how to check selectors, arguments and mutability against solc’s output
- what stays unknown, and how to keep an AI agent from overstating it
Prerequisites
- REA 6.4.0 (
npm install --global rea-agents). See Getting started with REA and MCP. - A Linux x64 host. The provider checks
process.platform === "linux"andprocess.arch === "x64"and otherwise refuses with: “Initial real-verified offline EVM interface profile supports Linux x64 only.” On macOS or Windows, use a Linux x64 VM, WSL2 on an x64 machine, or alinux/amd64container. - util-linux
prlimit, by default at/usr/bin/prlimit. REA uses it to put the EVMole worker under CPU, address-space and file-size limits. Check withcommand -v prlimit. If it lives elsewhere, setREA_EVM_PRLIMIT_COMMANDto its absolute path. REA doesn’t install system tools for you. - solc (the native compiler, not
solcjs, because we need--bin-runtimeand--hashes), plus Node.js/npm for the OpenZeppelin package andjq.
Nothing in this guide touches a chain. If you later pull bytecode from a network or interact with a deployed contract, do that only for contracts and systems you’re authorized to examine, and read DISCLAIMER.md.
Step 1: Compile a known contract to get ground truth
Start with a contract whose interface you know exactly. That’s how you calibrate your reading of an inferred result.
mkdir -p /work/evm && cd /work/evm
npm init -y >/dev/null
npm install @openzeppelin/contracts@5
mkdir -p contracts
cat > contracts/MyToken.sol <<'EOF'
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MyToken is ERC20 {
constructor() ERC20("MyToken", "MTK") {
_mint(msg.sender, 1_000_000e18);
}
}
EOF
solc --bin-runtime --hashes --abi --optimize \
--base-path . --include-path node_modules \
-o out --overwrite contracts/MyToken.sol
What you get in out/, and why each file matters:
MyToken.bin-runtime: the runtime bytecode as hex. That’s the code that lives at a contract address and contains the dispatcher. Don’t use--binhere. That’s creation (init) code, which deploys the runtime code, and REA can’t tell the two apart for you (bytecode.kindis alwaysunknown).MyToken.signatures: solc’s method identifiers, oneselector: signaturepair per line under aFunction signatures:heading. Recent solc versions add error and event sections to the same file. Only the function section matters here. This is your answer key.MyToken.abi: the ABI, including each function’sstateMutability.
solc also writes files for imported contracts such as ERC20 and IERC20. Use the MyToken ones.
For reference, the ERC-20 standard functions have well-known selectors (the first four bytes of the Keccak-256 hash of the signature). Among them are transfer(address,uint256) = 0xa9059cbb, balanceOf(address) = 0x70a08231, approve(address,uint256) = 0x095ea7b3 and totalSupply() = 0x18160ddd. Your .signatures file should list these plus the rest of OpenZeppelin’s ERC-20 surface.
Step 2: Run inspect-evm-interface
rea inspect-evm-interface /work/evm/out/MyToken.bin-runtime hex --json > /work/evm/mytoken.evm.json
Both arguments are positional and both are required:
- path: the local “bytecode carrier” file. The CLI resolves it to an absolute path.
- encoding:
hexorraw. There’s no auto-detection, on purpose.hexaccepts UTF-8 hex with an optional0xprefix and surrounding ASCII whitespace (so a trailing newline is fine). It rejects odd-length hex, separators inside the string, a BOM and invalid UTF-8.rawmeans the file’s bytes are the bytecode.
Limits are fixed: input up to 4 MiB, reply up to 16 MiB, a 256 MiB JavaScript heap in the worker, and a 30-second deadline. When a limit is hit, the run fails. There’s no partial success.
Over MCP the call is the same:
{ "name": "inspect_evm_interface", "arguments": { "path": "/work/evm/out/MyToken.bin-runtime", "encoding": "hex" } }
The MCP path must be absolute. The repository’s main-branch docs also describe a detail: "summary" argument. The 6.4.0 input schema is strict and accepts only path and encoding, so don’t send detail to a 6.4.0 server.
Step 3: Read the result
The command prints a standard REA Evidence record. The payload is in normalized_result:
jq '.normalized_result | {artifact, bytecode: (.bytecode | del(.hex)), evidence_kind, discovery_completeness, runtime_execution}' /work/evm/mytoken.evm.json
jq -r '.normalized_result.functions[] | [.selector, .dispatch, .inferred_arguments, .inferred_state_mutability, .bytecode_offset] | @tsv' /work/evm/mytoken.evm.json
Field by field:
artifact: the carrier file’s path, size, encoding and SHA-256. That hash covers the file exactly as written,0x, newline and all.bytecode: SHA-256 and length of the decoded bytes, plus the full hex. Note that neither digest is the Ethereum Keccak code hash, so don’t compare these values with an explorer’s code hash.kind,hardforkanddeployment_authenticityare alwaysunknown.functions[]: one row per candidate.selectoris shown as0xplus 8 hex digits.bytecode_offsetis a byte offset into the decoded bytecode, not into the hex text.dispatchisabiorfallback.inferred_argumentsis a comma-separated type string such asaddress,uint256.inferred_state_mutabilityholds EVMole’s guess. Either of the last two can benull.evidence_kind: "inferred",discovery_completeness: "unknown",runtime_execution: "not-performed": fixed values. They mean exactly what they say.limitations: the provider’s own caveats. One of them reads: “Recovered selectors, argument strings and mutability are upstream inferences, not verified ABI signatures or names. Empty results do not prove the absence of callable dispatch.”raw_result(top level of the Evidence): EVMole’s original reply. When selectors are requested it also includes any CBOR metadata it parsed from the end of the bytecode, such as the compiler’s metadata hash. REA keeps those values but makes no claims about their precision.
Step 4: Check the result against solc
Now compare the inferred selectors with the answer key:
cd /work/evm
jq -r '.normalized_result.functions[] | select(.dispatch == "abi") | .selector | ltrimstr("0x")' mytoken.evm.json | sort > inferred.txt
awk '/^Function signatures:/{f=1; next} /^$/{f=0} f' out/MyToken.signatures | cut -d: -f1 | sort > truth.txt
comm -3 truth.txt inferred.txt # empty output = same selector set
Then mutability:
jq -r '.[] | select(.type == "function") | "\(.name)\t\(.stateMutability)"' out/MyToken.abi
Pair each ABI entry with its inferred row through the selector in .signatures, and look at where they disagree. REA’s docs warn that optimized code may produce different mutability candidates. A function solc calls view can look different in the bytecode, depending on what the optimizer kept. Recompile without --optimize and run Step 2 again to see how much your settings matter. That exercise is worth more than any table we could print here, because the answer depends on your compiler version and settings.
The pattern to take away: selectors are usually the most reliable part of the output, argument types less so, and mutability the least. Because you verified a known contract first, you have evidence for that ranking instead of an assumption.
Step 5: Apply it to bytecode without source
With a calibrated reading, move to the bytecode you actually care about:
- A CTF level. Ethernaut’s level contracts are open source in the OpenZeppelin/ethernaut repository. Compile one with the
pragmaversion it declares, then repeat Steps 2 to 4. The CTF guide covers the general agent workflow for challenges. - A deployed contract you’re allowed to examine. REA won’t fetch it. Get the runtime code yourself (for example, with your own node or an explorer’s verified-contract page), save it as a hex file, and record where it came from. REA’s result will still say
deployment_authenticity: "unknown", because it can’t know whether the bytes you saved are what the chain holds. Preferably start with a verified contract whose published source lets you check the result.
Names are your job, and you need to cite them. REA does no signature lookup. If you match a selector to a name using a public signature database, label it as a database match. Four-byte selectors collide, so several signatures can share one selector. A selector that appears in your own .signatures file is ground truth. A database hit is only a hypothesis.
Using it with an AI agent
The output is compact enough for an agent to read in full. Use a prompt that enforces the boundaries:
Run inspect_evm_interface on /work/evm/target.hex with encoding hex.
For each function row, report selector, dispatch, inferred_arguments and
inferred_state_mutability exactly as returned, plus the Evidence ID.
Do not invent function names. If I provide a .signatures file, match against it
and mark matches "solc ground truth"; anything else stays "unnamed".
Quote the limitations array verbatim at the end. Do not describe the contract's
logic: this tool does not recover it.
Ask for the Evidence ID on every line. That gives you the same traceability you’d expect from the native workflow in REA Evidence, snapshots and version comparison. Over MCP the record is also kept in the session bundle for export_evidence_bundle.
Troubleshooting
“Initial real-verified offline EVM interface profile supports Linux x64 only.” You’re on macOS, Windows, or Linux on ARM. Use a Linux x64 VM, WSL2 on x64, or a linux/amd64 container.
“Offline EVM interface inspection requires caller-supplied util-linux prlimit: …” prlimit is missing, or REA_EVM_PRLIMIT_COMMAND isn’t an absolute path to a regular executable. Install util-linux or fix the variable.
unsupported_target mentioning EF00. The bytecode starts with 0xEF00, the EOF container format. 6.4.0 only handles flat legacy bytecode.
Hex format error. Remove interior spaces or line breaks (a hex dump split across lines won’t parse) and make sure the length is even. Keep a single 0x at most.
Few or no functions found. You probably passed creation code (--bin) instead of runtime code (--bin-runtime), or the contract is a minimal proxy whose logic lives elsewhere. An empty list isn’t proof that there’s no callable interface.
Results differ between two compiles of the same source. Different optimizer settings or compiler versions give different bytecode. Compare the bytecode.sha256 values first.
Next steps
- The agent-driven challenge workflow: Solving CTF reverse-engineering challenges with REA.
- Keep the results reproducible: REA Evidence, snapshots and version comparison.
- Install and connect REA to your client: Getting started with REA and MCP.
- Give your agent a standing reverse-engineering workflow: Claude Code reverse engineering skill with REA.
- Reference: docs/evm-bytecode.md, which is newer than 6.4.0 in places, as noted above.