Back to Blogresearch

One Line, All Funds: How a Static IV Turned Nightly Wallet's Encryption Into a Two-Time Pad

A single const declaration at module scope reused the same AES-CTR initialization vector for every encryption call, enabling full mnemonic and private key recovery from any Nightly Wallet vault — without ever knowing the user's password.

ExVul Security Research Team

ExVul Security Research Team

Security Researchers

March 25, 202615 min
#Wallet Security#Cryptography#Browser Extension#0day#Vulnerability
One Line, All Funds: How a Static IV Turned Nightly Wallet's Encryption Into a Two-Time Pad

Severity: Critical (CVSS 9.1) | CWEs: CWE-329, CWE-1204 | Version tested: 1.45.11 | Status: Patched (2026-03-19)

Introduction

When we audit browser extension wallets, we expect to find the usual suspects — weak key derivation, missing origin checks, leaky message passing. What we did not expect to find in Nightly Wallet was a textbook cryptographic vulnerability so clean and so devastating that we could recover every user's mnemonic phrase, HD seed, and private keys across every chain — without ever knowing their password.

The culprit? A single const declaration at module scope.

The Target

Nightly Wallet is a multi-chain browser extension wallet supporting Solana, EVM chains, Aptos, Sui, NEAR, Polkadot, and more. Like most extension wallets, it encrypts the user's secrets — BIP39 mnemonic, HD seed, per-chain private keys — inside a "vault" locked by a user-chosen password, then stores the vault in chrome.storage.local.

The encryption algorithm is AES-256-CTR, a perfectly sound choice — when used correctly.

What We Found

Deep in the minified bundle (chunk-utils2.js, ~5.6 MB), the encryption module initializes like this:

chunk-utils2.js (deobfuscated)
// Variable names deobfuscated for clarity
const ALGORITHM = "aes-256-ctr";
const STATIC_IV = crypto.randomBytes(16); // Generated ONCE at module load
const encrypt = (plaintext, key) => {
const cipher = crypto.createCipheriv(ALGORITHM, key, STATIC_IV);
const data = Buffer.concat([cipher.update(plaintext), cipher.final()]);
return { iv: STATIC_IV.toString("hex"), data: data.toString("hex") };
};

That second line is the entire vulnerability. STATIC_IV is generated once when the service worker boots, then reused verbatim for every single encryption call during the worker's lifetime. The variable appears exactly three times in the entire codebase — initialization, use in createCipheriv, and serialization to hex — and is never reassigned.

During wallet creation, the extension encrypts the mnemonic, the HD seed, the checksum string "Nighly <3", and every per-chain private key. All of them use the same password-derived key. All of them use the same IV. Every ciphertext stored in the vault shares an identical keystream.

This is the textbook definition of a two-time pad — the cryptographic equivalent of reusing a one-time pad.

Why IV Reuse Breaks AES-CTR

AES-CTR works by generating a pseudorandom keystream from the key and IV, then XORing it with the plaintext: C = P ⊕ AES(K, IV || Counter)

The security guarantee depends on the keystream being unique for every encryption. When two plaintexts P₁ and P₂ are encrypted with the same key and IV:

Two-Time Pad
C = P KS
C = P KS
XOR the ciphertexts together and the keystream cancels out:
C C = P P

The attacker now has the XOR of two plaintexts. The encryption key — and therefore the password — is completely irrelevant. All the attacker needs is access to the stored ciphertexts in chrome.storage.local.

The Vault Layout: A Crib-Dragger's Dream

The vault structure makes exploitation trivial. During wallet creation, the Mimir class encrypts:

Vault Structure
{
essentials: {
encryptedMnemonic: encrypt(mnemonic, passwordHash),
encryptedSeed: encrypt(hexSeed, passwordHash),
checksum: encrypt("Nighly <3", passwordHash), // Known plaintext!
},
accounts: {
SOLANA: [{ encryptedPrivKey: encrypt(base58Key, passwordHash) }],
EVM: [{ encryptedPrivKey: encrypt(hexPrivKey, passwordHash) }],
// ... every chain, same key, same IV
}
}

Three properties make this exploitable far beyond a generic two-time pad:

  • Known plaintextThe checksum is always "Nighly <3" — 9 bytes of free keystream recovery.
  • Constrained alphabetThe HD seed is a 128-character hex string ([0-9a-f]), providing a powerful filtering constraint at every byte position.
  • Dictionary-constrained textThe mnemonic consists only of words from the 2048-word BIP39 wordlist, separated by spaces.

Together, these constraints turn what would otherwise be a hard crib-dragging problem into a highly tractable one.

The Attack: Recovering a Mnemonic Without the Password

We built a fully automated proof-of-concept in Python (no external dependencies) that recovers the mnemonic in two complementary modes.

Mode 1: Single Wallet Recovery

Given a single wallet's vault data extracted from chrome.storage.local:

Step 1 — Known plaintext XOR. XOR the checksum ciphertext with the mnemonic ciphertext. Since the checksum plaintext is known ("Nighly <3"), this immediately reveals the first 9 bytes of the mnemonic — typically the entire first word and the start of the second.

Step 1 Result
checksum_ct mnemonic_ct = "Nighly <3" mnemonic[:9]
Recovered: "cruel tur"
First word: "cruel", second word starts with "tur..."

Step 2 — Seed hex constraint filtering. XOR the mnemonic ciphertext with the seed ciphertext to get mnemonic ⊕ seed_hex. Since the seed is hex-only, every candidate mnemonic byte must XOR with the corresponding position to produce a character in [0-9a-f]. This is an extremely powerful filter — on average, only 16/256 (6.25%) of byte values pass at each position.

Step 3 — Word-by-word BIP39 solver. Recursively try each of the 2048 BIP39 words at each position in the mnemonic, rejecting immediately if any byte fails the hex constraint. A 12-word mnemonic (~74 bytes) fits entirely within the 128-byte seed ciphertext, so the constraint applies to every character.

Step 4 — BIP39 checksum validation. The last 4 bits of a 12-word mnemonic are a SHA-256 checksum of the entropy. This filters hundreds of hex-valid candidates down to dozens.

Step 5 — PBKDF2 seed derivation. For definitive confirmation, derive the BIP39 seed from each candidate mnemonic via PBKDF2-HMAC-SHA512 (2048 iterations) and compare against the XOR-recovered seed bytes.

Result against real vault data:

PoC Output
[Step 0] IV Reuse Verification
Checksum IV: 899c23330d0e2e9b036cda07be91b61a
Mnemonic IV: 899c23330d0e2e9b036cda07be91b61a
Seed IV: 899c23330d0e2e9b036cda07be91b61a
All identical: YES VULNERABLE
[Step 1] Known Plaintext Recovery
Recovered mnemonic[0:9]: "cruel tur"
[Step 2] Crib-Dragging (seed hex constraint)
Hex-valid word sequences: 672
BIP39 checksum valid: 43
Confirmed words: 5/12
Word 1: cruel
Word 2: turtle
Word 3: firm
Word 4: appear
Word 5: turkey

Five words definitively confirmed in seconds. The remaining 7 positions narrowed from 2048 possibilities each to just 43 total BIP39-valid candidates.

Mode 2: Multi-Wallet Crib-Dragging

When two wallets share the same IV (which happens whenever both are created in the same browser session — the default case), we can recover both mnemonics simultaneously without needing the checksum or seed:

  • Pre-compute a BIP39 word-pair XOR table (~1.6M entries)
  • Match the XOR of the two mnemonic ciphertexts against the table to bootstrap initial keystream bytes
  • Extend byte-by-byte, filtering at each position: both recovered plaintexts must contain only lowercase letters and spaces, and partial words must be valid BIP39 word prefixes (prefix pruning)
  • Validate both BIP39 checksums

Result: both 12-word mnemonics fully recovered in 3.1 seconds. No password, no brute-force.

Impact

What's ExposedHowPassword Needed?
BIP39 mnemonic (12-24 words)Crib-dragging + structural constraintsNo
HD seed (hex)XOR against recovered mnemonicNo
All per-chain private keys (SOL, EVM, APT, SUI, NEAR, ...)Cross-key XOR + known encoding formatsNo

Attack Prerequisites

An attacker needs access to the chrome.storage.local data — achievable via:

  • Malware or infostealer with filesystem access
  • Physical access to an unlocked machine
  • A malicious browser extension with storage permission
  • A browser vulnerability

Crucially, the wallet's vault password provides zero protection against this attack. The encryption may as well not exist.

Scope

  • Every Nightly Wallet installation is affectedThe vulnerable code path is the only encryption path.
  • RetroactiveVaults created before any fix remain vulnerable unless re-encrypted with fresh, unique IVs.
  • SilentThe attack requires no interaction with the extension and leaves no traces.

Root Cause

The entire vulnerability traces to a single architectural decision: generating the IV at module scope rather than inside the encrypt function.

Vulnerable Code
// ❌ VULNERABLE — IV lives at module scope, generated once
const STATIC_IV = crypto.randomBytes(16);
const encrypt = (plaintext, key) => {
const cipher = crypto.createCipheriv("aes-256-ctr", key, STATIC_IV);
// ...
};
Fixed Code
// ✅ FIXED — fresh IV per encryption call
const encrypt = (plaintext, key) => {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-ctr", key, iv);
// ...
};

The fix is one line. Move crypto.randomBytes(16) inside the function.

Why It Went Unnoticed

The insidious thing about this bug is that everything works correctly from the user's perspective. Encryption and decryption succeed. The IV is stored alongside each ciphertext, creating the appearance that per-ciphertext IVs are being used. Only by comparing the stored IV values across multiple ciphertexts does the reuse become apparent — and no existing test did that.

Recommendations

For the Nightly Team

  • Generate a fresh random IV per encryption callThe one-line fix shown above.
  • Force re-encryption of all existing vaultsOn next unlock — decrypt with the old IV, re-encrypt each value with a unique IV.
  • Migrate to AES-256-GCMAuthenticated encryption detects tampering and makes nonce reuse detectable rather than silently catastrophic.
  • Replace the password KDFSHA-256(password).base64().substr(0,32) is a separate weakness that amplifies this one. Use PBKDF2 (≥600K iterations) or Argon2id with a per-vault salt.
  • Add regression tests for IV uniquenessA trivial test that encrypts twice and asserts the IVs differ would have caught this.
Recommended Test
test('encrypt produces unique IVs', () => {
const c1 = encrypt('data1', key);
const c2 = encrypt('data2', key);
expect(c1.iv).not.toEqual(c2.iv);
});

For Extension Wallet Users

  • If you use Nightly Wallet, consider your mnemonic and private keys potentially exposed if any malware, malicious extension, or unauthorized person has ever had access to your browser profile
  • Transfer funds to a wallet generated on a different, uncompromised wallet application
  • Do not reuse the compromised mnemonic

For Extension Wallet Developers

  • Never generate cryptographic nonces or IVs at module/class scope — they must be created fresh inside the encryption function
  • CTR mode is unforgiving — a single IV reuse completely breaks confidentiality. If you use CTR, enforce IV uniqueness at the API level or switch to GCM where nonce reuse is at least detectable
  • Include IV uniqueness assertions in your test suite — the test is trivial; the bug it prevents is catastrophic

Disclosure Timeline

DateEvent
2026-03-04Vulnerability discovered and report submitted to Nightly team
2026-03-04Vendor acknowledgment
2026-03-19Patch release

References

One Line, Total Compromise

A single const declaration at module scope — generating an IV once instead of per-call — completely nullified AES-256-CTR encryption for every secret in the vault.

Password Irrelevant

The two-time pad attack recovers plaintexts by XORing ciphertexts together. The encryption key (and thus the user's password) is never needed.

Structural Constraints Accelerate Recovery

Known plaintext (checksum), hex-constrained seed, and BIP39 dictionary constraints reduce the problem from theoretical crib-dragging to automated recovery in seconds.

Test What Matters

A single assertion — encrypt twice, check IVs differ — would have caught this before any release. Cryptographic invariants must be tested explicitly.

Related Articles

Continue reading about blockchain security