High in the Vault on Aptos: when an address parameter leaks the secret

Pavel EginPavel Egin
11 min read

Greeting 👋

Whoami 😎

Hi! I’m Pavel (aka kode-n-rolla) — a security researcher and penetration tester focused on web, cloud, and increasingly Web3. I’m committed to continuous learning and rigorous, hands-on practice. Lately I’ve leaned deep into blockchain security: Aptos/Move and Solidity/EVM—because decentralization aligns with my mission to make technology safer for everyday users. 🔐

What I do: security research, code review, and exploit development across:

  • Web & API security: auth flaws, logic bugs, SSRF/IDOR, injection, broken access control

  • Cloud (AWS-centric): IAM, logging/monitoring, misconfig hunting

  • Web3 security: Move modules (Aptos), Solidity contracts (Foundry), test-driven PoCs, threat modeling

I love turning complex systems into clear attack surfaces and reproducible tests, so teams can fix fast and ship safer. If this resonates, I’d be glad to connect on LinkedIn. 🤝

Next, a brief note on CodeHawk for context—and then we’ll dive into the analysis, PoC, and mitigation. 🚀

What is CodeHawks?

CodeHawks (by Cyfrin) is a competitive Web3 security platform where researchers audit real codebases, submit PoCs, and get scored or paid based on impact. It runs two main tracks:

  • Competitive Audits — prize pools 💰, real-world targets, severity-based scoring, private judging, public leaderboards.

  • First Flights — guided, points/XP-based challenges 🏁 designed for hands-on learning and ramp-up (no cash prizes, but great for skill and reputation).

Why it’s great for hands-on security

  • Real code, real bugs: audited repos, clear scope, reproducible environments.

  • Repeatable PoCs: tests and scripts encouraged (Foundry/Move unit tests, CLI flows).

  • Actionable feedback & reputation: submissions get validated; points and write-ups build a portfolio 📈.

How a typical contest flows

  1. Read the scope & rules.

  2. Set up the local env (tooling, testnet/devnet).

  3. Hunt: threat model → code review → write PoCs.

  4. Submit: description, impact, likelihood, PoC, and mitigation.

  5. Validation & scoring → leaderboard/results.

In this article, I walk through one such First Flight: the Aptos/Move “Secret Vault task — from threat model to High-severity finding, PoC, and a clean fix. 🚀

Test process

Note-taking matters (seriously) 📝

Corrected line:
First of all, note everything you find during your testing. Use Notion, Obsidian, Word — or even the nano CLI editor or vim — whatever is comfortable for you.

What to capture (minimal but rigorous):

  • Context: repo URL, commit hash, branch, scope files.

  • Env: OS, Aptos CLI version, tool versions, node URLs/ports.

  • Commands & outputs: exact CLI lines, test filters, tx hashes, return values.

  • Timestamps: when you ran each step (helps correlate with logs).

  • Artifacts: unit tests, diffs, screenshots (console/explorer), JSON responses.

  • Decisions & dead ends: why you pivoted; what didn’t reproduce.

  • Links: explorer views, PRs, docs you relied on.

Tip: prefix headings with YYYY-MM-DD HH:MM and tag sections ([hypothesis], [poc], [mitigation]) so you can lift material straight into the final report.

Project Setup & Goal

We start with a concise spec, a repo link, and a short dependency list. If your toolchain isn’t ready, install the prerequisites, clone the repo, and compile:

Requirements

  • git

    • You'll know you did it right if you can run git --version and you see a response like git version x.x.x
  • Aptos CLI

    • You'll know you did it right if you can run aptos --version and you see a response like aptos 3.x.x
  • Move

git clone https://github.com/CodeHawks-Contests/2025-07-secret-vault.git
cd 2025-07-secret-vault
aptos move compile --dev

Goal (as stated): store a secret for the owner and allow only the owner to retrieve it.
Target chain: Aptos. Contract language: Move. 🔐


A (very) short Move primer

Move is different enough from Solidity that a quick mental model pays off. Below is a tight overview you can keep in mind while reading audits and writing PoCs.

1) What is Move?

Move is a smart-contract language originally built for Libra/Diem; today it powers Aptos, Sui, and a few emerging chains. Its superpower is resource-oriented programming:

  • Values of certain types are resources: they can’t be copied or implicitly destroyed.

  • Ownership and safety are enforced by the type system, not by conventions.

  • The model reduces entire classes of bugs (e.g., typical EVM reentrancy patterns). ✅

2) How Move differs from Solidity

  • Solidity: state lives inside contract storage; access control is usually enforced via modifiers, msg.sender, and conventions around visibility.

  • Move: you model state as resources owned by accounts. If a resource is “yours,” others can’t move or duplicate it unless the module explicitly allows it. The compiler and VM enforce these rules.

3) Core concepts you’ll meet in this audit

a) Modules

A module is roughly “a contract.” It is published at an on-chain address and defines types, resources, and functions.

Note: Hashnode doesn’t support Move syntax highlighting yet, so all Move snippets below use rust highlighting for readability.

module 0xC0FFEE::vault {
    // types, resources, functions
}

b) Resources

A resource is a first-class type with linear semantics:

resource struct Vault { secret: string::String }
  • Resources cannot be copied or dropped accidentally.

  • They are typically stored under an account’s address and accessed through that module’s API.

c) Storage (where things live)

Accounts have their own storage. To put a resource into an account’s storage, you move it there:

public entry fun store_secret(s: &signer, data: vector<u8>) {
    let v = Vault { secret: string::utf8(data) };
    move_to(s, v); // store under signer’s address
}

You’ll also see helpers like exists<T>(addr), borrow_global<T>(addr), and borrow_global_mut<T>(addr) when reading/updating resources.

d) signer (authentication)

&signer represents the authenticated caller (the account that signed the transaction).
Crucial difference vs. EVM: you don’t read caller identity from a global like msg.sender; you receive it explicitly as a parameter.

public entry fun store_secret(caller: &signer, ...) { /* authenticate via signer */ }

⚠️ Audit red flag: authorizing by a user-supplied address instead of &signer is a logic bug waiting to happen.

e) Function kinds (entry, public, #[view])

  • entry fun — externally callable via transactions.

  • public fun — callable by other modules or internally.

  • #[view] — read-only public function (no state mutation), but still part of your public API; it must be properly authorized like any other external function.


Pocket checklist for Move audits (keep this handy) 🧠

  • Auth: Prefer &signer for access control; be skeptical of functions that take an address parameter for “who you are.”

  • Storage shape: Where is the resource stored? Under which address? How is it read/updated?

  • Named addresses: Bindings (e.g., @owner) are public after publish; don’t rely on obscurity.

  • Views leak too: #[view] cannot mutate, but it can exfiltrate sensitive data if auth is broken.

  • No copies of resources: Updates typically use borrow_global_mut<T>(addr), not “re-move_to.”

The Code (annotated)

module secret_vault::vault {
    use std::signer;
    use std::string::{Self, String};
    use aptos_framework::event;

    const NOT_OWNER: u64 = 1;

    resource struct Vault { secret: String }

    #[event]
    struct SetNewSecret {}

    // Write path
    public entry fun set_secret(caller: &signer, secret: vector<u8>) {
        let v = Vault { secret: string::utf8(secret) };
        move_to(caller, v);              // store under caller’s address
        event::emit(SetNewSecret {});
    }

    // Read path (bug)
    #[view]
@>  public fun get_secret(caller: address): String acquires Vault {
@>      assert!(caller == @owner, NOT_OWNER);   // auth by user-supplied param, not signer
@>      let v = borrow_global<Vault>(@owner);   // always reads the owner’s slot
@>      v.secret                                 // leaks the secret
    }
}

Why this matters (step by step)

  1. Ownership & storage shape: set_secret persists Vault under the caller’s address via move_to(caller, …).

  2. Public read API: get_secret is #[view] (read-only) but still public and must be properly authorized.

  3. Broken auth primitive: it authorizes via a user-supplied address (caller) instead of the actual caller (&signer).

  4. Named address is public: @owner is bound at publish time and is discoverable on-chain (bytecode/explorer).

  5. Exfil path: any account can pass owner_addr to get_secret(owner_addr) and receive the secret.

  6. Impact: direct, irreversible disclosure of the owner’s secret (core invariant violated).

Hypothesis

If get_secret authorizes by a user-supplied address (parameter) instead of the actual caller (&signer), then any account can pass owner_addr and read the owner’s secret. This remains practical because the @owner binding is publicly observable after publish.


Writing the minimal test (one file, one assertion)

Note: Hashnode doesn’t support Move syntax highlighting yet, so Move snippets below use rust.

#[test(owner = @0xcc, attacker = @0x123)]
fun test_leak_owner_secret_via_view(owner: &signer, attacker: &signer) acquires Vault {
    use aptos_framework::account;

    // 1) Arrange: create users and store a secret under the owner's address
    account::create_account_for_test(signer::address_of(owner));
    account::create_account_for_test(signer::address_of(attacker));
    let s = b"i'm a secret";
    set_secret(owner, s);

    // 2) Act: call the public #[view] with the owner's address (not the caller!)
    let owner_addr = signer::address_of(owner);
    let leaked = get_secret(owner_addr);

    // 3) Assert: the returned value equals the original secret → unauthorized read
    assert!(leaked == string::utf8(s), 100);
}

What this test proves (acceptance criteria):

  • The read path is authorized by parameter, not by signer.

  • Passing owner_addr is sufficient to exfiltrate the secret.

  • The assertion verifies a real data disclosure, not just a control-flow quirk.


Run and result:

└──╼ $aptos move test -f test_leak_owner_secret_via_view
INCLUDING DEPENDENCY AptosFramework
INCLUDING DEPENDENCY AptosStdlib
INCLUDING DEPENDENCY MoveStdlib
BUILDING aptos-secret-vault
Running Move unit tests
[ PASS    ] 0x234::vault::test_leak_owner_secret_via_view
Test result: OK. Total tests: 1; passed: 1; failed: 0
{
  "Result": "Success"
}

Expected: the test passes, confirming the hypothesis and establishing a High-severity unauthorized disclosure.

Fix / Mitigation

Root principle: authorize by the actual caller (&signer), not by a user-supplied address. Return a copy of the stored string, not a reference.

@@
- #[view]
- public fun get_secret(caller: address): String acquires Vault {
-     assert!(caller == @owner, NOT_OWNER);
-     let v = borrow_global<Vault>(@owner);
-     v.secret
- }
+ #[view]
+ public fun get_secret(caller: &signer): String acquires Vault {
+     // Authenticate by signer, not by a parameter
+     assert!(signer::address_of(caller) == @owner, NOT_OWNER);
+     let v = borrow_global<Vault>(@owner);
+     // Return a copy; do not expose a reference to global storage
+     string::clone(&v.secret)
+ }

Optionally harden writes so only the owner can set/update the secret and so updates happen in place:

@@
- public entry fun set_secret(caller:&signer, secret: vector<u8>) {
-     let v = Vault { secret: string::utf8(secret) };
-     move_to(caller, v);
-     event::emit(SetNewSecret {});
- }
+ public entry fun set_secret(caller:&signer, secret: vector<u8>) acquires Vault {
+     assert!(signer::address_of(caller) == @owner, NOT_OWNER);
+     if (exists<Vault>(@owner)) {
+         let v = borrow_global_mut<Vault>(@owner);
+         v.secret = string::utf8(secret);
+     } else {
+         move_to(caller, Vault { secret: string::utf8(secret) });
+     }
+     event::emit(SetNewSecret {});
+ }

Security invariants after the fix

  • Only the owner (authenticated via &signer) can read or write the secret.

  • #[view] get_secret no longer authorizes by attacker-controlled input.

  • No references to global storage are leaked; a safe copy is returned.


Regression tests (minimal)

#[test(owner = @0xcc)]
fun test_owner_can_read_after_fix(owner: &signer) acquires Vault {
    use aptos_framework::account;
    account::create_account_for_test(signer::address_of(owner));
    set_secret(owner, b"fixed");
    let got = get_secret(owner);
    assert!(got == string::utf8(b"fixed"), 200);
}

#[test(owner = @0xcc, attacker = @0x123)]
#[expected_failure(abort_code = 1)] // NOT_OWNER
fun test_non_owner_cannot_read_after_fix(owner: &signer, attacker: &signer) acquires Vault {
    use aptos_framework::account;
    account::create_account_for_test(signer::address_of(owner));
    account::create_account_for_test(signer::address_of(attacker));
    set_secret(owner, b"fixed");
    // get_secret now requires &signer, so passing attacker must abort
    let _ = get_secret(attacker);
}

Note: Hashnode doesn’t support Move syntax highlighting yet, so Move snippets below use rust highlighting for readability.

If you prefer to keep writes open (per-user vaults), adjust the write policy accordingly—but never authorize reads/writes by a user-supplied address.

Reporting & Submission (CodeHawks) 🦅

On CodeHawks, the submission form mirrors a standard audit write-up. Keep it tight, evidence-driven, and reproducible.

Recommended structure:

  1. Title — concise, impact-first (e.g., “High — Unauthorized secret disclosure via address-parameter auth”).

  2. Severity — Impact + Likelihood with one-line justification each.

  3. Description

    • Intended behavior: what the module promises.

    • Actual behavior: what happens instead.

  4. Root Cause — short reasoning plus a code excerpt (mark relevant lines).

  5. Attack Path — step-by-step how an attacker reaches the state.

  6. Proof of Concept — unit test (preferred) and/or CLI repro; exact commands.

  7. Risk — when it occurs, who can trigger it, blast radius, data affected.

  8. Recommended Mitigation — minimal diff; explain why it fixes the issue.

  9. Regression Tests — the one or two tests that must pass post-fix.

  10. Appendix — env info, commit hash, logs, screenshots, references.

After submission

After a final pre-submit sanity check (typos, missing steps, wrong commit hash, etc.), all that’s left is to wait for the “Valid” tag and prize/bounty allocation. That waiting period is the hardest part of our job 😅.


Conclusions 🏁

  • Security hygiene in code: Prefer &signer-based authorization over user-supplied addresses; treat #[view] as a full-fledged public API; remember named addresses are public after publish; avoid leaking data via events; return copies (e.g., string::clone) instead of references to global storage.

  • Testing discipline: Keep unit tests and live CLI repros side by side; add regression tests with clear acceptance criteria; document threat models and invariants; make PoCs deterministic and reproducible.

  • Notes & craft: Meticulous notes win audits—capture env, commands, outputs, timestamps, dead ends, and decisions. Clean diffs, clear commits, respectful write-ups.

  • Mindset: Stay curious and enjoy the work. Good energy → better reviews; bad mood → more bugs slip through. Protect the craft. 🔐✨

Further reading & contact:

  • GitHub — security library, tools, hints, cheatsheets, etc.

  • Medium — deep dives (mostly Web2/AppSec)

  • X (Twitter) — quick findings and threads

Thanks for reading, and happy hacking (ethically 😉)!

See ya! 👋

0
Subscribe to my newsletter

Read articles from Pavel Egin directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Pavel Egin
Pavel Egin