Value Substitution Through a Mutable Reference
Category: Values & References · Covered by: Yours · Requires: Aptos Move 2.2 or later, for function values
What this prevents
A value is validated, a mutable reference to it is handed to code the module does not control, and the module then reads the value again. The callee replaces the whole value with a different one of the same type, so the second read returns something that was never checked.
Encapsulation is not broken at any point. The callee never reads or writes a private field; it does
not need to. std::mem::swap is public, and it operates on &mut T without knowing anything about
T’s internals.
Coverage
Nothing on the platform covers this. The bytecode verifier’s reference safety pass is real and strong: no dangling references, no aliasing violations, safe access through mutable references. It says nothing about the identity of the value behind a reference, because identity is not a reference-safety property.
That gap is worth stating precisely, because the verifier’s strength here invites the wrong conclusion. A reference that survives verification is a reference that will not be dangling and will not alias unsafely. It is not a reference to the value you were looking at a moment ago.
What is left to you
Every &mut T that crosses a trust boundary is an access-control decision. The boundary is easy to
miss because it does not look like one: a callback parameter, a hook, a caller-supplied function
value, a dispatch through a stored closure.
Two questions are worth asking at each of those points. What did the module check about this value before the call, and is any of it still guaranteed after? And does the callee need to mutate at all, or would an immutable reference, or a returned replacement value, do the same job?
The check that catches substitution is an identity check, not a field check. Field checks pass happily on the substituted value, because it is the same type with plausible contents.
Sample and test
See src/sources/ValueSubstitution.move, exercised by
src/tests/ValueSubstitution_test.move. Four cases: the happy path, the attack, the attack
against the defended form, and a control showing the defence does not break ordinary use.
The second case deliberately passes. It asserts that a validated ticket comes back attributed to the attacker with a different amount, which is the pattern’s failure shown working rather than described.
Key excerpt:
public fun redeem(
t: &mut Ticket,
expected_issuer: address,
on_redeem: |&mut Ticket| has drop,
): u64 {
assert!(t.issuer == expected_issuer, E_WRONG_ISSUER);
on_redeem(t); // arbitrary code, holding &mut
t.amount // not necessarily the ticket that was checked
}
The defended form captures what it validated and re-checks it afterwards:
let issuer_before = t.issuer;
let amount_before = t.amount;
on_redeem(t);
assert!(t.issuer == issuer_before, E_SUBSTITUTED);
assert!(t.amount == amount_before, E_SUBSTITUTED);
Review questions
- Does any public function hand
&mut Tto a caller-supplied function value, hook, or dispatch? - What was validated about that value before the call, and is it re-established afterwards?
- Would an immutable reference serve, or a returned value the module can validate itself?
- If the value carries authority, such as a capability or an asset, is its identity checked rather than only its fields?
Trade-offs
- Pro: the defence is local. Capture what was checked, re-check it after the call, with no change to the callee’s interface.
- Con / cost: re-checking costs gas on every call, and it only covers the properties actually captured. A field nobody thought to capture is a field nobody will notice changing.
- Trust note: an API taking a caller-supplied function value is running arbitrary code at that point. Substitution is one thing that code can do; it is not the only one.
Historical note
std::mem::swap became publicly callable through AIP-105. Before that, replacing a value through a
reference was not something arbitrary code could do, so a check before a call and a read after it
were equivalent. Material written before that release reflects the earlier situation, which is why
this failure mode is easy to miss in older Move guidance.
References
- AIP-105, public
std::memoperations aptos-core,third_party/move/move-bytecode-verifier/src/reference_safety/mod.rs, for what reference safety does guarantee- Aptos, “Move Security Guidelines”, on passing mutable references to untrusted code