argotorg / argotorg/solidity

test/EVMHost.cpp: use-after-free on `accounts` references across recursive `EVMHost::call`

Open
#16,680 0 comments 0 reactions 1 assignee Claimed by @rodiazet View on GitHub
bug :bug: low effort low impact testing :hammer:
Dominant language
C++
Stars
25.7k
Forks
6.2k
Avg merge
1d 11h
Merged PRs (30d)
21

Description

# `test/EVMHost.cpp`: use-after-free on `accounts` references across recursive `EVMHost::call`

## Summary

`EVMHost::call` holds two references into the `accounts`
(`std::unordered_map`) member across the call to
`m_vm.execute(*this, …)`:

- `auto& sender = accounts[_message.sender];` (`test/EVMHost.cpp:299`)
- `auto& destination = accounts[message.recipient];` (`test/EVMHost.cpp:386`)

`m_vm.execute` dispatches `CREATE` / `CREATE2` / `CALL` opcodes back through
`*this`, recursively re-entering `EVMHost::call`. The recursive frame inserts
into `accounts` (lines 299, 384, 386 of the inner frame) and may also
reassign `accounts = stateBackup` on its own failure paths (lines 313, 374,
398, 446). Either path can reallocate the hash-table bucket array, after
which the outer frame's `sender` / `destination` references dangle.

The outer frame then dereferences them. The first guaranteed UAF write is at
`test/EVMHost.cpp:439–440`, after the inner `m_vm.execute` returns:

```cpp
destination.code = evmc::bytes(result.output_data, …); // line 439 — UAF write
destination.codehash = convertToEVMC(keccak256(…)); // line 440 — UAF write
```

`transfer(sender, destination, value)` at line 401 is also UAF after any
prior reallocation (in scenarios where the inner reallocation occurs from
`code = accounts[message.code_address].code;` on line 384 — but typically
the relevant rehash happens during the post-execute path).

## Affected version

```
$ ./build/solidity/solc/solc --version
solc, the solidity compiler commandline interface
Version: 0.8.35-develop.2026.5.6+commit.b83005c9.Linux.g++
```

Repository: `argotorg/solidity` @ `b83005c900d356e82f4d2da52be1601e1d8b3539`.

The pattern has been in `test/EVMHost.cpp` at least since the "Introduce
experimental EVM version" commit (`a99633adb1`). The bug is in the test
*support* code (`test/EVMHost.cpp`), not in the compiler itself.

## Why it's a real bug, not a transient

`std::unordered_map` explicitly invalidates references and pointers to its
elements on rehash (`[unord.req]`), and `operator=` from a different-sized
map deallocates the old bucket array unconditionally. The references taken
on lines 299 and 386 outlive at least one operation that can do either:

1. The inner `EVMHost::call` (reached via `m_vm.execute`) calls
`accounts[…]` for its own `sender` / `code_address` / `recipient` — any of
these can trigger a rehash if the resulting size crosses the load-factor
threshold.
2. Any inner failure path (insufficient balance, OOG at depth 0, CREATE2
collision, non-success result) executes `accounts = stateBackup;` — this
is a copy-assignment from a same-typed map, deallocating the existing
buckets and allocating fresh ones.

In both cases, the outer frame's `destination.code = …` write at line 439
is a use-after-free.

## Reproduction

Any input that performs enough nested `CREATE`s for the outer frame's
`accounts` map to grow past the rehash threshold during the inner
`m_vm.execute` will trigger it. The minimal source we hit it with:

```solidity
contract A {
uint x;
address immutable owner;
constructor() { owner = msg.sender; }
function setX(uint _x) public { require(msg.sender == owner); _x; }
function getX() public view returns (uint) { return x; }
}

contract B {
A a;
constructor() {
a = new A();
assert(a.getX() == 0);
}
function getX() public view returns (uint) { return a.getX(); }
}

contract C {
B b;
constructor() {
b = new B(); b = new B();
b = new B();
b = new B();
}
function f() public view {
assert(b.getX() == 0);
}
}
```

Deploying `C` performs four nested `new B()` (each of which does `new A()`
and an external `STATICCALL`), which is enough to make
`accounts.size()` cross the default rehash threshold during the outer
deploy.

We hit this in the `solidity-fuzzing` differential runner (a fuzz tool that
links a near-verbatim copy of `test/EVMHost.cpp`); glibc reliably aborts
with `malloc(): unaligned tcache chunk detected` or `free(): invalid size`
on every run. Under valgrind, the first error is shown below.

### Valgrind output (trimmed)

```
==…== Invalid read of size 8
==…== at std::__cxx11::basic_string, std::allocator>::operator=(…&&)
==…== by EVMHost::call(evmc_message const&) <-- writing destination.code
==…== Address 0x… is 40 bytes inside a block of size 240 free'd
==…== at operator delete(void*, unsigned long)
==…== by std::__detail::_Hashtable_alloc<… MockedAccount …>::_M_deallocate_nodes
==…== by EVMHost::call(evmc_message const&) <-- recursive frame, accounts insert/rehash
==…== by evmc::internal::call(evmc_host_context*, evmc_message const*)
==…== by evmone::instr::core::create_impl<(evmone::Opcode)240>(…)
==…== by evmone::baseline::execute(…)
==…== by EVMHost::call(evmc_message const&) <-- outer frame, holds destination&
==…== Block was alloc'd at
==…== by std::__detail::_Map_base<…MockedAccount…>::operator[](evmc::address&&)
==…== by EVMHost::call(evmc_message const&) <-- outer frame, took destination&
```

(Three errors at this site: `Invalid read of size 8`, `Invalid write of
size 1`, `Invalid write of size 8` — they are the three accesses inside the
`std::basic_string` move-assignment that implements `destination.code =
evmc::bytes(…)`.)

## Impact

- `test/EVMHost.cpp` is the host used by `soltest`, the semantic-test
framework, and downstream fuzzers (including the OSS-Fuzz harnesses in
`test/tools/ossfuzz/`).
- The corruption is silent in most semantic tests because the heap is not
perturbed enough for the dangling write to land on critical metadata. It
becomes visible (a) under sanitizers, (b) under valgrind, and (c) when
the fuzzer happens to choose a layout that puts allocator metadata next
to the freed bucket — which is what we see consistently with this input.
- ASan-built `soltest` runs are very likely already detecting this, but
perhaps it's been masked because the failing test is variable.

## Notes

- The pattern is purely test-side; the compiler is not implicated.
- `evmc::MockedHost` (in `test/evmc/mocked_host.hpp`) is the upstream that
defines `accounts` as `std::unordered_map`. This isn't an evmc bug per se
— evmc's own use sites do not hold references across nested host calls.
The bug is solidity's `EVMHost::call` adding the reference-hoisting
pattern on top.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.