~/f4n6 $ grep -r "Anatomy of a Crypto Clipboard Hijacker: Nine Wallets, Zero Victims" ./investigations/ --include="*.md"
Local LLM

Anatomy of a Crypto Clipboard Hijacker: Nine Wallets, Zero Victims

Jeff Davies 15 Sep 2026 9 min read

A 15 KB DLL that silently rewrites cryptocurrency addresses on your clipboard. I pulled it apart statically, confirmed it in a sandbox, recovered all nine of the attacker's wallets and found every one of them empty.

Every so often a malware sample turns out to be almost elegant. Not in a good way, but in the way that a locksmith might admire a particularly clean lock: minimal parts, no wasted motion, doing exactly one thing very well.

This is one of those. A 15,360-byte Windows DLL whose entire purpose is to sit quietly inside a running process and change cryptocurrency wallet addresses on the clipboard before you paste them.

No C2. No persistence. No network capability at all. Just a clipboard, a list of wallet formats, and the attacker's nine addresses.

I found it in a fresh MalwareBazaar upload, took it apart, and ended up recovering the complete attacker wallet table then went looking for the money. What I found was interesting in a different way.

The sample

SHA-256   d354462b118dd49dac255c2361c2c8f17a6e02e0c74942556d9763a83bf4e6ab
Size      15,360 bytes
Type      PE32+ executable (DLL), x86-64, GUI subsystem, 5 sections
Compiled  2026-09-05
First seen on VT  2026-09-15
Detections  23/74 (ESET: Win64/ClipBanker_AGen.AA)

At the time I looked, it had been submitted to VirusTotal exactly once, by whoever uploaded it to MalwareBazaar. That's about as fresh as malware gets.

Two things stand out immediately. First, the file is named .exe but the PE header carries the IMAGE_FILE_DLL characteristic it's a DLL wearing the wrong extension. Second, it has no exports at all. The export directory is empty (size zero).

That combination tells you a lot before you read a single instruction. A DLL with no exports can't be invoked through the normal rundll32 some.dll,SomeExport path. It doesn't need to be: the author only cares about DllMain, which Windows calls automatically when the module loads. The .exe extension is camouflage, it helps the file blend into a directory of ordinary-looking executables, and it matters less than you'd think, because the loader doesn't care about the extension either.

The smoking gun is the import table

Here is the complete list of everything this binary can do:

KERNEL32.dll:  GlobalAlloc, GlobalFree, GlobalLock, GlobalUnlock,
               LocaleNameToLCID, Sleep

USER32.dll:    CloseClipboard, EmptyClipboard, GetClipboardData,
               GetClipboardSequenceNumber, OpenClipboard, SetClipboardData

Twelve functions. Six of them are clipboard operations. The others are memory allocation for moving data around and Sleep for pacing.

There is no socket, no InternetOpen, no WinHttpSendRequest, no CreateFile, no registry API, no CreateProcess. This malware cannot phone home. It cannot persist. It cannot download a second stage. It cannot exfiltrate anything.

For a piece of "banking malware," that's a startling capability set until you realise that this isn't the whole malware. It's a component. Somewhere upstream there's a loader that delivered it and got it running; whatever C2 that loader uses is in a different binary. This one is a single-purpose payload, deliberately stripped to the minimum.

A DllMain that never returns

The decompiled entry point is short and, once you see it, unmistakable:

if (param_2 != 1) return 1;          // DLL_PROCESS_ATTACH only
GetClipboardSequenceNumber();         // poll until the clipboard changes
do {
    if (OpenClipboard(NULL)) {
        hMem = GetClipboardData(0xd);   // CF_UNICODETEXT
        // copy it, run it through a matcher...
        if (replaced) {
            EmptyClipboard();
            SetClipboardData(0xd, newBuffer);
        }
        CloseClipboard();
    }
} while (true);                       // <-- never exits

That while (true) is doing double duty, and it's the cleverest thing in the file.

DllMain is never supposed to block. The loader holds a lock while it runs, and a DllMain that doesn't return will hang whatever thread is loading it. But that's precisely the point: when rundll32.exe loads this DLL, LoadLibrary blocks forever inside DllMain, the process never exits, and the malware's polling loop keeps running for as long as the machine is up.

It also neatly sidesteps the missing-exports problem. rundll32 will fail to find the entry point it was asked for, but by then DllMain has already run and the process is wedged in the loader. The payload doesn't need to be called; it just needs to be loaded.

The loop itself is a straightforward clipper. GetClipboardSequenceNumber is a cheap way to detect that the clipboard changed without touching its contents, so the malware does nothing until there's something new. When the sequence number moves, it opens the clipboard, reads CF_UNICODETEXT, hands the text to a matcher, and — if the matcher found a wallet address — empties the clipboard and writes the replacement back.

The victim never sees anything. Whatever they copied appears to paste normally. It's just no longer their address.

Finding the wallet table

The interesting part is the matcher, and specifically what it's matching against.

A .data section that is exactly 88 bytes long is a hint. 88 bytes is eleven 8-byte pointers an array, pointing into .rdata, one entry per supported coin. Follow them and you land on eleven short buffers of high-entropy bytes.

High entropy in a 15 KB file with no packing is a contradiction. The .text section sits at 6.3 entropy, normal for compiled code, and nothing is compressed. So those eleven buffers aren't strings they're encrypted strings, and the decryption has to be somewhere in .text.

It is, and it looks like this in the disassembly:

mov  r8, rdx
xor  r8, 0xfffffffffffffffb
mov  r9, rdx
or   r9, 4
and  r8, r9
add  r9, rax
movsx r8d, BYTE PTR [r8+r9*1]     ; table[(i|4) + ((i^-5)&(i|4))]
...
mov  r10d, r9d
and  r10d, r8d
add  r9d, r8d
add  r10d, r10d
sub  r9d, r10d                     ; a + b - 2*(a & b)  ==  a ^ b

That index arithmetic is the compiler's way of writing i + 4, and the final sequence is just XOR wearing a disguise a + b - 2(a & b) is the identity for a ^ b in two's complement. Compilers emit it; obfuscators like it because it doesn't look like XOR at a glance.

So the decoder is buff[i] ^ buff[i+4], writing the result into a UTF-16 buffer. Apply that and the first four bytes of the Bitcoin slot come out as b c 1 q.

Which is the Bitcoin bech32 prefix. The structure is confirmed but only the first four characters decode cleanly, and everything after that is garbage.

That's the tell that the cipher is layered. The first four bytes work because i+4 reaches into the second half of a four-byte block. The rest needs a second term, and the obvious candidate is the previous plaintext block: this smells like a cumulative chain rather than a simple XOR.

When the regression finally collapsed, it collapsed to a one-liner:

def decode(c, n):
    p = bytearray()
    for i in range(n):
        v = c[i] ^ c[i + 4]
        if i >= 4:
            v ^= p[i - 4]        # chain back one 4-byte block
        p.append(v)
    return bytes(p)

P[i] = C[i] ^ C[i+4] ^ P[i-4]. Each four-byte block of plaintext depends on the block four positions earlier, so the ciphertext can't be attacked block-by-block. Every slot decodes cleanly.

Proving it, twice

A decode that produces plausible-looking strings is not the same as a correct decode. I wanted two independent confirmations.

The first was arithmetic. Every cryptocurrency address format carries a checksum, and a checksum is unforgiving: change one byte of the payload and the address stops validating. So I wrote a validator for each format — base58check for Bitcoin-legacy/Litecoin/TRON, the Ripple alphabet for XRP, bech32 for Bitcoin-segwit and Cardano, cashaddr's polymod for Bitcoin Cash, EIP-55's Keccak-256 mixed-case scheme for Ethereum, and CRC16-XMODEM for TON.

Eight of the nine addresses passed. That is not a thing that happens by accident. A single bit error anywhere in the decode would have broken the corresponding checksum.

The ninth, slot 10, fails base58check but it decodes to exactly 32 bytes, which is the signature of a Solana public key. Solana addresses carry no checksum by design: they're raw 32-byte ed25519 keys in base58. The chain's own RPC accepted it as a well-formed account key, which is as close to confirmation as that format allows.

The second confirmation was to stop reading and start running. I built a harness that stages the DLL inside a Windows 10 guest, seeds the clipboard with known test addresses, launches the payload through rundll32, and dumps the clipboard after each round.

The result, side by side:

seeded:   1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa
pasted:   bc1qsr3dpnq0r78ghk2rc5ee7qtd97jsnxdjrt8hk7

seeded:   0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe
pasted:   0x7AF990F219E0899402C3A3263c497C77737997b3

seeded:   TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t
pasted:   TNFWs1YABtGQ6XzPaBx2M1ZgaHdawpNR8c

Every replacement matched the corresponding entry in my static decode, character for character. Control rounds run before the payload launched showed the clipboard untouched, which rules out the harness contaminating its own results.

The wallets

Here is the complete attacker table, with checksum status and chain activity as of 2026-09-15:

# Chain Address Checksum On-chain activity
0 Cardano addr1qxz2sl3jh2jv28az8lqr4dudp4z7cnv8quk9z6hxczldw5yy4plr9w4yc506y07q82mc6r29a3xcwpev294wds976agqqk9p5l valid not on chain
1 Bitcoin Cash qr2zlxm253204y2erll75j585x56xf43l5e2e90mwh valid 0 transactions
3 Bitcoin bc1qsr3dpnq0r78ghk2rc5ee7qtd97jsnxdjrt8hk7 valid 0 transactions
4 Ethereum 0x7AF990F219E0899402C3A3263c497C77737997b3 valid 0 transactions, 0 token transfers
5 Litecoin Le3LPiQpj3aJ83F7BXFywJ56xLjccjVsuJ valid 0 transactions
6 TON UQDM4sfaoI8pp9IO_8DHbJehVuO0-3o4zwLXmGCtOsz7LXH5 valid account does not exist
7 TRON TNFWs1YABtGQ6XzPaBx2M1ZgaHdawpNR8c valid 0 transactions
8 XRP rQBRprGKBtjmvZubMEcwJPLMj3mc72cCdZ valid never activated
10 Solana Da12Pnk92R3yAJjbPvNzWwMRkNuG1RiNfgupzAd9LPAu n/a (none by design) account does not exist

Slots 2 and 9 are empty — the author allocated eleven and configured nine.

Following the money

Every single wallet is empty. Not "recently swept" the on-chain records say they have never been used at all.

That distinction matters, and it's worth being precise about it. A zero balance alone proves nothing: funds could have arrived and been moved out. So for each chain I checked for history, not just balance.

  • Bitcoin: tx_count = 0 on blockstream.info, cross-checked against mempool.space. No transactions exist, ever.
  • Ethereum: Blockscout reports zero transactions, zero token transfers, zero gas usage, and a nonce of zero. An address that had ever received funds would show up somewhere in that set.
  • XRP: the ledger returns actNotFound. XRP accounts don't exist until they're funded, so this address has never received anything.
  • Cardano: the chain query returns an empty result not a zero balance, but the absence of the address from the ledger entirely.
  • TON: account status nonexist.

Infrastructure that has been deployed but never used.

What "zero victims" actually means

There are a few readings, and they're worth separating.

The most likely is simply timing. The sample was compiled on 2026-09-05 and surfaced publicly ten days later. If this is a freshly deployed campaign, the wallets may be empty because there hasn't been enough time the payload has to be delivered, has to land on a machine where someone copies a wallet address, and that person has to paste it into a payment flow before anything moves. That's a narrow funnel, and it takes time to fill.

The second is that this is a staged but unlaunched build the wallet table was generated and embedded, the loader hasn't been pointed at targets yet. Staging addresses in advance is normal; you need them in the binary before you can build it.

The third is less comfortable: this could be one variant of a larger operation where the other variants carry different addresses, and this particular table simply hasn't been the one to catch anything. A single empty table doesn't prove a single unlucky attacker.

What it does not mean is that anyone is safe. Every element of the mechanism works. I watched it swap three addresses in a sandbox, and the substitutions were format-perfect right prefix, right length, right checksum. There is no way to spot the switch by looking at it.

Detection

Hashes are useful once, but the structural properties are more durable. An unsigned DLL that polls the clipboard and republishes it is inherently suspicious, whatever its hash and it doesn't matter that it has no exports, because DllMain runs regardless.

A YARA rule built on the invariant shape of the thing:

rule Win_ClipBanker_ClipboardHijack_DLL
{
    meta:
        description = "Detects an x64 DLL built purely to poll and rewrite the clipboard"
        author = "clipbanker analysis, 2026-09-15"
        reference = "d354462b118dd49dac255c2361c2c8f17a6e02e0c74942556d9763a83bf4e6ab"
        severity = "high"
    condition:
        uint16(0) == 0x5a4d and
        uint32(uint32(0x3c)) == 0x00004550 and
        pe.is_pe and
        pe.machine == pe.MACHINE_AMD64 and
        pe.characteristics & pe.DLL and
        pe.number_of_exports == 0 and
        pe.imports("USER32.dll", "GetClipboardSequenceNumber") and
        pe.imports("USER32.dll", "OpenClipboard") and
        pe.imports("USER32.dll", "SetClipboardData") and
        pe.imports("USER32.dll", "EmptyClipboard") and
        pe.imports("USER32.dll", "GetClipboardData") and
        pe.imports("KERNEL32.dll", "GlobalLock") and
        not pe.imports("KERNEL32.dll", "LoadLibrary")
}

Behaviourally, the delivery shape is equally distinctive: rundll32.exe loading an unsigned DLL from a user-writable directory.

title: Unsigned Export-Less DLL Hosted in rundll32
logsource:
  product: windows
  category: process_creation
detection:
  sel_img:
    Image|endswith: '\rundll32.exe'
  sel_susp_dir:
    CommandLine|contains:
      - '\AppData\'
      - '\Temp\'
      - '\ProgramData\'
      - '\Users\Public\'
  condition: sel_img and sel_susp_dir
level: medium

Takeaways

The absence of network capability isn't reassuring. It's the opposite. This binary is small and quiet because someone deliberately stripped it down to one job and that design choice means the interesting parts (the delivery chain, the C2, the operator) are somewhere else on disk. Finding a loader-shaped file next to a component-shaped file tells you where to dig.

Checksums are an analyst's best friend. Eight of nine addresses validating their own format-specific checksum did more to confirm the decode was byte-exact than any amount of staring at decompiled output. If you recover embedded data from malware, find a checksum and let it vote.

Always check for history, not just balance. A zero balance is ambiguous. tx_count = 0, a nonce of zero, actNotFound, and an account that doesn't appear in the ledger are not those say never used.

Cheap, single-purpose payloads are a trend worth tracking. No packer, no anti-VM tricks, no obfuscation beyond a bit of XOR and some compiler-shaped arithmetic. The author spent their effort on the one thing that matters the substitution being invisible and left everything else minimal. That's a resource-conscious, modular operation, and the components are reusable across campaigns.

Nine wallets, all empty, all waiting. So far.


All analysis was performed in an isolated environment. Wallet addresses are published as threat intelligence — they are attacker-controlled and should be screened in blockchain analytics tooling, not interacted with. Raw indicators and the full technical report are available on request.

Post this to LinkedIn
Formatting is converted automatically — headings, bullets, a link back & hashtags. Paste straight in.
J
Jeff Davies
// read next

Related