Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
BlockchainMedium

Rustroll Web3

CTF writeup for Rustroll Web3 from Next Hunt

//RustRoll Writeup

>TL;DR

  • The rollup truncates account IDs to 20 bits from the low bits of blake3(pubkey), but verifies signatures with the full Ed25519 key. By brute-forcing Ed25519 keys until blake3(pubkey) mod 2^20 matched the Vault address (622488), we forged a sender identity.

  • Once we found such a key, we signed a transaction from the Vault with the correct nonce and the magic amount, sending funds to our own account. The node accepted it and returned the flag: nexus{Th1s-Is_A-Hard#Fl4g!}.

>Recon

  1. Connect to the service:

   ```bash

   nc 4.211.248.144 8080

   ```

   Banner shows commands HELP | INFO | LIST | TX <hex> with a 120-byte transaction layout: from_addr | to_addr | amount | nonce | pubkey (32) | signature (64).

  1. Inspect parameters:

   ```

   INFO

   ```

   - vault_addr = 622488

   - magic_amount = 13371337

   - address_bits = 20

  1. Check known accounts:

   ```

   LIST

   ```

   Example early state: Vault at 622488 with a high balance and nonce 4 (later 7 after on-chain activity).

>Vulnerability

Addresses are derived from truncated identities. The server computes an address from a public key using only 20 bits. The signature check uses the full Ed25519 key, so any key whose truncated address matches a target account is accepted as that account. Formally:

  • Let $h = \operatorname{blake3}(\text{pubkey})$.

  • Address $= h \bmod 2^{20}$.

With only $2^{20}$ possibilities, we can brute-force keys until the derived address equals the Vault address (622488).

>Finding a colliding key

We brute-forced Ed25519 keys and tested blake3(pubkey) low 20 bits until it equaled 622488.

python

import nacl.signing, blake3, time

mask = (1 << 20) - 1

TARGET = 622488

start = time.time()

tries = 0

while True:

    sk = nacl.signing.SigningKey.generate()

    pk = sk.verify_key.encode()

    addr = int.from_bytes(blake3.blake3(pk).digest(), 'little') & mask

    tries += 1

    if addr == TARGET:

        print('found after', tries, 'in', time.time() - start, 's')

        print('seed', sk._seed.hex())

        print('pub', pk.hex())

        break

Result (example):

  • seed = 6e71110e265c2821e4a055af275cf47da12d0390bc6e0b737e00ea6cd3da34e1

  • pub  = 75176a35bd142cd5472b9b9338bd21f804fd67c8177753d7d0a6ecacae1d948f

  • Address from this key: 622488 (matches the Vault)

  • Search time: a few seconds (~200k trials)

>Crafting the transaction

The transaction format is 120 bytes: <IIQQ | pubkey(32) | sig(64)> with little-endian integers.

Set fields:

  • from_addr = 622488 (Vault)

  • to_addr   = 0 (our receiver)

  • amount    = 13371337 (magic amount)

  • nonce     = 8 (current Vault nonce observed via LIST)

Sign the 24-byte prefix with the forged key and append pubkey + signature:

python

import struct, socket, nacl.signing

seed = bytes.fromhex('6e71110e265c2821e4a055af275cf47da12d0390bc6e0b737e00ea6cd3da34e1')

sk = nacl.signing.SigningKey(seed)

msg = struct.pack('<IIQQ', 622488, 0, 13371337, 8)

sig = sk.sign(msg).signature

tx_hex = (msg + sk.verify_key.encode() + sig).hex()

  

s = socket.create_connection(('4.211.248.144', 8080))

s.recv(1024)               # banner

s.sendall(b'TX ' + tx_hex.encode() + b'\n')

print(s.recv(4096).decode())

The server replied OK and immediately printed the flag.

>Flag

nexus{Th1s-Is_A-Hard#Fl4g!}

>Key takeaways

  • Truncating identities to save space is dangerous: collisions are easy to brute-force when only 20 bits are checked.

  • Signature verification must bind to the exact public key, not a lossy projection of it.

  • Always re-derive addresses from the same function used to authorize ownership, or include the full public key in the authenticated message.