One Shot: Reloaded

First Flight #47
Beginner FriendlyNFT
100 EXP
Submission Details
Impact: high
Likelihood: high

Predictable RNG and central mint/custody enable deterministic battle wins and fund/NFT theft

Author Revealed upon completion

Root + Impact

// rap_battle.move

public fun resolve_battle(defender: address, challenger: address, ...) {

let ts = timestamp::now_seconds(); // @> Predictable RNG source

let rand = (ts + defender_stat + challenger_stat) % 100; // @> Directly used to pick winner

if (rand Single authority is held by @battle_addr with no multisig or limits


public fun mint_cred(signer: &signer, amount: u64) {

// Only signer with capability (module owner) can mint

// @> Allows arbitrary inflation of CRED

}

// streets.move

resource struct Custody { owner: address, object_id: ObjectId }

// @> NFTs transferred into module custody, no safe withdrawal path


public fun stake_rapper(rapper: ObjectId, owner: address) {

// @> Object moved into module account, requires module signer for release

}




Description


Normal behavior

Players mint Rapper NFTs, stake them in streets to earn CRED, then enter rap_battle where two players wager equal CRED and the protocol computes an on-chain battle winner. Winners receive the pooled CRED and the Rapper accrues a win.


Observed issue

The battle outcome entropy is derived directly from timestamp::now_seconds() (or another predictable on-chain timestamp) and the module owner (@battle_addr) centrally mints/burns CRED and custodies NFTs. An attacker who times transactions (or colludes with/compromises the module owner) can bias outcomes and drain prize pools or mint/transfer CRED and NFTs without owner consent.

Risk

Likelihood:

  1. When an attacker times a challenger transaction to match a second that yields a favorable rand result (deterministic outcome).

  2. When an attacker compromises or colludes with the module owner (or module owner acts maliciously), enabling arbitrary minting of CRED or withholding NFTs.


Impacts:

  • Direct loss of staked/wagered CRED for players.

  • Arbitrary inflation of CRED supply (value destruction / exploitable trading).

  • Permanent loss/lock of Rapper NFTs if module signer lost or malicious.

  • Reputation / marketplace damage.

Proof of Concept

  1. Deploy the repository locally to an Aptos devnet (Aptos CLI 7.7.0, same modules)

  2. Create accounts: battle_addr (module owner), alice (defender), bob (challenger).

  3. battle_addr mints two Rapper NFTs to alice and bob.

  4. Repeat the following loop while logging the timestamp::now_seconds() observed in transactions:

In second S: alice calls defend(bet, rapper_id_alice)

Immediately in same second S: bob calls challenge(bet, rapper_id_bob)

Record winner. Repeat at S+1, S+2..

5.If winner correlates deterministically with ts (same ts -> same winner), RNG is predictable.

Minimal PoC unit-test idea (attach as tests/predictable_rng_test.move): call battle resolution repeatedly while stubbing or observing timestamp::now_seconds() and assert outcomes are correlated to timestamp.


fun battle(defender: &mut Rapper, challenger: &mut Rapper, bet: u64) {
let now = timestamp::now_seconds(); //@> Root cause: predictable RNG
let rand = now % 2;
if (rand == 0) {
// challenger wins
challenger.wins = challenger.wins + 1;
// transfer prize pool...
} else {
// defender wins
defender.wins = defender.wins + 1;
// transfer prize pool...
}
}

Recommended Mitigation

  • Replace timestamp::now_seconds() with a verifiable randomness source (VRF, oracle, or commit–reveal).

  • Use block metadata + hashing or player commitments to generate unpredictable entropy.

  • Remove direct dependency on timestamps, which are predictable and miner-influenced.

- let now = timestamp::now_seconds(); //@> Root cause: predictable RNG
- let rand = now % 2;
+ // Use a verifiable random source instead of timestamp
+ // Example approaches:
+ // 1. Integrate Aptos VRF (when available) or an off-chain randomness oracle.
+ // 2. Derive randomness from unpredictable on-chain values (block hash, event seed)
+ // combined with cryptographic hashing.
+ // 3. Require commit–reveal scheme between players to generate entropy fairly.
+
+ // Pseudocode (commit–reveal sketch):
+ let seed = hash(player_commitment || block_metadata_hash);
+ let rand = seed % 2;

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.