Skip to the content.

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

Trade-offs

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