Skip to content
Menu
The Lonely Administrator
  • PowerShell Tips & Tricks
  • Books & Training
  • Essential PowerShell Learning Resources
  • Privacy Policy
  • About Me
The Lonely Administrator

Friday Fun with PowerShell and Alternate Data Streams

Posted on February 18, 2022February 18, 2022

I have always had a peculiar fascination with alternate data streams. This is an NTFS feature that has been around for a long time. The feature, also referred to as ADS, allows a user to write data to a hidden fork of a file. You can store practically anything in an alternate data stream without affecting the reported file size. As with anything, there is always the chance of abuse or worse. But I'm going to assume you have adequate security in place. You cannot disable ADS, so maybe we can do something useful with it.

Manage and Report Active Directory, Exchange and Microsoft 365 with
ManageEngine ADManager Plus - Download Free Trial

Exclusive offer on ADManager Plus for US and UK regions. Claim now!

Finding Streams

The first step is to learn how to identify alternate data streams in a file. You can use Get-Item and the Streams parameter. Fortunately, the parameter accepts wildcards.

get alternate data stream

The stream :$DATA is the default stream for the file contents. You'll find this on every file. Here's a file that includes a second data stream.

an additional alternate data stream

I put together this simple PowerShell script to make it easier to identify extra alternate data streams.

#requires -version 5.1

Param([string]$Path = "*.*")

Get-Item -Path $path -stream * | Where-Object {$_.stream -ne ':$DATA'} |
Select-Object @{Name="Path";Expression = {$_.filename}},
Stream,@{Name="Size";Expression={$_.length}}
get alternate data stream with PowerShell

Getting Stream Content

I can see that these files have alternate data streams. But what is in them? Let's look.

get stream content

The Stream parameter does not accept wildcards, so you need to know the name in advance. No problem. I'll use my script.

C:\scripts\Get-AlternateDataStream.ps1 .\*.db | Select Path,Stream,@{Name="Content";Expression={Get-Content $_.path -stream $_.stream}}
get alternate stream content

You could easily turn my code into a PowerShell function.

Creating Alternate Data Streams

To create your own alternate data streams, you can use the other Content cmdlets.

dir *.zip | Set-Content -Stream "source" -value "over the rainbow"
getting new alternate data stream content

What kind of information could we store that would be independent of the file contents? How about a version number?

set-content .\Get-AboutOnline.ps1 -Stream version -Value "0.9.0"

Or maybe author information?

set-content .\Get-AboutOnline.ps1 -Stream author -Value "Jeff Hicks"
new alternate stream metadata

Adding this information does not alter the file contents nor change the file size. But it will update the LastModfiedTime.

Clearing Streams

You can easily clear the content of a stream.

clear-content .\vm.db -Stream secret

The stream itself remains with nothing in it. To delete the stream completely, use Remove-Item.

remove-item .\vm.db -Stream secret

Scripting with Alternate Data Streams

With this information, why not build some tools to use alternate data streams with our PowerShell scripting. I wrote a wrapper function around Set-Content to add a version alternate data stream.

Function Set-VersionStream {
    [cmdletbinding(SupportsShouldProcess)]
    Param(
        [Parameter(
            Position = 0,
            Mandatory,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName,
            HelpMessage = "Specify a file path."
        )]
        [ValidateScript({ (Test-Path $_) -AND ((Get-Item $_).psprovider.name -eq 'FileSystem') })]
        [Alias("fullname")]
        [string[]]$Path,
        [parameter(Mandatory, HelpMessage = "Specify a version string")]
        [alias("version")]
        [string]$Value,
        [Parameter(HelpMessage = "Specify the name of the version stream. The default is 'versionInfo'.")]
        [string]$Stream
    )
    Begin {
        Write-Verbose "[$((Get-Date).TimeofDay) BEGIN  ] Starting $($myinvocation.mycommand)"
        if (-Not ($psboundparameters.containskey["Stream"])) {
            $psboundparameters.Add("Stream", "versionInfo")
        }
    } #begin

    Process {
        Write-Verbose "[$((Get-Date).TimeofDay) PROCESS] Adding version $value to stream $($psboundparameters['Stream']) in $Path"
        Set-Content @psboundparameters
    } #process

    End {
        Write-Verbose "[$((Get-Date).TimeofDay) END    ] Ending $($myinvocation.mycommand)"
    } #end

} #close Set-VersionStream

The default stream name is versionInfo, but you can specify something else or modify the code.

set a version information alternate data stream

I can do something similar with author information.

Function Set-AuthorStream {
    [cmdletbinding(SupportsShouldProcess)]
    Param(
        [Parameter(
            Position = 0,
            Mandatory,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName,
            HelpMessage = "Specify a file path."
        )]
        [ValidateScript({ (Test-Path $_) -AND ((Get-Item $_).psprovider.name -eq 'FileSystem') })]
        [Alias("fullname")]
        [string[]]$Path,
        [parameter(Mandatory, HelpMessage = "Specify an author string")]
        [alias("author")]
        [string]$Value,
        [Parameter(HelpMessage = "Specify the name of the author stream. The default is 'authorInfo'")]
        [string]$Stream
    )
    Begin {
        Write-Verbose "[$((Get-Date).TimeofDay) BEGIN  ] Starting $($myinvocation.mycommand)"
        if (-Not ($psboundparameters.containskey["Stream"])) {
            $psboundparameters.Add("Stream", "authorInfo")
        }
    } #begin

    Process {
        Write-Verbose "[$((Get-Date).TimeofDay) PROCESS] Adding author $value to stream $($psboundparameters['Stream']) in $Path"
        Set-Content @psboundparameters
    } #process

    End {
        Write-Verbose "[$((Get-Date).TimeofDay) END    ] Ending $($myinvocation.mycommand)"
    } #end

} #close Set-AuthorStream
set author information via alternate data stream

The function accepts pipeline input, so I can quickly set this on multiple files.

dir *.ps1 | Set-AuthorStream -Value "Jeff Hicks"

Since I have a command to set the value, it might be handy to have a command to get the value and do more than giving me a simple string.

Function Get-AuthorStream {
    [cmdletbinding()]
    Param(
        [Parameter(
            Position = 0,
            Mandatory,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName,
            HelpMessage = "Specify a file path."
        )]
        [ValidateScript({ (Test-Path $_) -AND ((Get-Item $_).psprovider.name -eq 'FileSystem') })]
        [Alias("fullname")]
        [string[]]$Path,

        [Parameter(HelpMessage = "Specify the name of the author stream. The default is 'authorInfo'")]
        [string]$Stream
    )
    Begin {
        Write-Verbose "[$((Get-Date).TimeofDay) BEGIN  ] Starting $($myinvocation.mycommand)"
        $splat = @{
            ErrorAction = "Stop"
        }

        if ($psboundparameters.containskey["Stream"]) {
            $splat.Add("Stream", $psboundparameters.containskey["Stream"])
        }
        else {
            $splat.Add("Stream", "authorInfo")
        }
    } #begin

    Process {
        foreach ($item in $path) {
            $splat['Path'] = Convert-Path $item
            Write-Verbose "[$((Get-Date).TimeofDay) PROCESS] Getting author stream $($psboundparameters['Stream']) from $item"
            Try {
                $info = Get-Content @splat
                [pscustomobject]@{
                    Path       = $splat.path
                    AuthorInfo = $info
                }
            }
            Catch {
                Write-Warning "The stream $Stream was not found on $path"
            }
        }
    } #process

    End {
        Write-Verbose "[$((Get-Date).TimeofDay) END    ] Ending $($myinvocation.mycommand)"
    } #end

} #close get-AuthorStream

However, since there isn't a limitation to what I can store in an alternate data stream, why not store something richer? Something that looks like this:

I wrote a proof-of-concept function to store this information as a JSON in an alternate data string.

Function Set-AuthorStreamJson {
    [cmdletbinding(SupportsShouldProcess)]
    Param(
        [Parameter(
            Position = 0,
            Mandatory,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName,
            HelpMessage = "Specify a file path."
        )]
        [ValidateScript({ (Test-Path $_) -AND ((Get-Item $_).psprovider.name -eq 'FileSystem') })]
        [Alias("fullname")]
        [string[]]$Path,
        [parameter(Mandatory, HelpMessage = "Specify an author name")]
        [string]$Author,
        [parameter(Mandatory)]
        [string]$Company,
        [parameter(Mandatory)]
        [string]$Version,
        [string]$Comment,
        [Parameter(HelpMessage = "Enter the creation tile. It will saved as a UTC formatted string")]
        [datetime]$Created = (Get-Date),
        [Parameter(HelpMessage = "Specify the name of the author stream. The default is 'authorInfo'")]
        [string]$Stream
    )
    Begin {
        Write-Verbose "[$((Get-Date).TimeofDay) BEGIN  ] Starting $($myinvocation.mycommand)"
        $splat = @{
            ErrorAction = "Stop"
        }

        if ($psboundparameters.containskey["Stream"]) {
            $splat.Add("Stream", $psboundparameters["Stream"])
        }
        else {
            $splat.Add("Stream", "authorInfo")
        }

        $json = [PSCustomObject]@{
            Author  = $author
            Company = $company
            Version = $version
            Created = "{0:u}" -f $created
            Comment = $comment
        } | ConvertTo-Json

    } #begin

    Process {
        foreach ($item in $Path) {
            $splat["Path"] = Convert-Path $item
            $splat["Value"] = $json
            Write-Verbose "[$((Get-Date).TimeofDay) PROCESS] Adding author information for $author to stream $($splat['Stream']) in $item"
            write-verbose $json
            Set-Content @splat
        }

    } #process

    End {
        Write-Verbose "[$((Get-Date).TimeofDay) END    ] Ending $($myinvocation.mycommand)"
    } #end

} #close Set-AuthorStreamJson

The stream is a JSON string that I can convert into an object. Or I can use a PowerShell function.


Function Get-AuthorStreamJson {
    [cmdletbinding()]
    Param(
        [Parameter(
            Position = 0,
            Mandatory,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName,
            HelpMessage = "Specify a file path."
        )]
        [ValidateScript({ (Test-Path $_) -AND ((Get-Item $_).psprovider.name -eq 'FileSystem') })]
        [Alias("fullname")]
        [string[]]$Path,

        [Parameter(HelpMessage = "Specify the name of the author stream. The default is 'authorInfo'")]
        [string]$Stream
    )
    Begin {
        Write-Verbose "[$((Get-Date).TimeofDay) BEGIN  ] Starting $($myinvocation.mycommand)"
        $splat = @{
            ErrorAction = "Stop"
        }

        if ($psboundparameters.containskey["Stream"]) {
            $splat.Add("Stream", $psboundparameters["Stream"])
        }
        else {
            $splat.Add("Stream", "authorInfo")
        }
    } #begin

    Process {
        foreach ($item in $path) {
            $splat['Path'] = Convert-Path $item
            Write-Verbose "[$((Get-Date).TimeofDay) PROCESS] Getting author stream $($splat.stream) from $item"
            Try {
                $info = Get-Content @splat
                $out = $info | ConvertFrom-Json
                $out | Add-Member -MemberType NoteProperty -Name Path -Value $splat.path -PassThru
            }
            Catch {
                Write-Warning "The stream $($splat.stream) was not found on $path"
            }
        }
    } #process

    End {
        Write-Verbose "[$((Get-Date).TimeofDay) END    ] Ending $($myinvocation.mycommand)"
    } #end

} #close get-AuthorStreamJson

The function will display a warning for files that lack the stream, so I'm suppressing the warning messages in this example.

Finally, how about adding tags?

Function Add-TagStream {
    [cmdletbinding(SupportsShouldProcess)]
    Param(
        [Parameter(
            Position = 0,
            Mandatory,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName,
            HelpMessage = "Specify a file path."
        )]
        [ValidateScript({ (Test-Path $_) -AND ((Get-Item $_).psprovider.name -eq 'FileSystem') })]
        [Alias("fullname")]
        [string[]]$Path,
        [parameter(Mandatory, HelpMessage = "Specify a set of tags")]
        [alias("tag")]
        [string[]]$Value,
        [Parameter(HelpMessage = "Specify the name of the Tags stream. The default is 'tags'.")]
        [string]$Stream
    )
    Begin {
        Write-Verbose "[$((Get-Date).TimeofDay) BEGIN  ] Starting $($myinvocation.mycommand)"
        if (-Not ($psboundparameters.containskey["Stream"])) {
            $psboundparameters.Add("Stream", "tags")
        }
    } #begin

    Process {
        Write-Verbose "[$((Get-Date).TimeofDay) PROCESS] Adding Tags $($value -join ',') to stream $($psboundparameters['Stream']) in $Path"
        Set-Content @psboundparameters
    } #process

    End {
        Write-Verbose "[$((Get-Date).TimeofDay) END    ] Ending $($myinvocation.mycommand)"
    } #end

} #close Add-Tagstream

This command makes it a snap to add tags.

dir *.csv,*.db | Add-TagStream -tag "data","company"

Of course, I want an easy way to get the tag stream.

Function Get-TagStream {
    [cmdletbinding()]
    Param(
        [Parameter(
            Position = 0,
            Mandatory,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName,
            HelpMessage = "Specify a file path."
        )]
        [ValidateScript({ (Test-Path $_) -AND ((Get-Item $_).psprovider.name -eq 'FileSystem') })]
        [Alias("fullname")]
        [string[]]$Path,

        [Parameter(HelpMessage = "Specify the name of the tag stream. The default is 'tags'")]
        [string]$Stream
    )
    Begin {
        Write-Verbose "[$((Get-Date).TimeofDay) BEGIN  ] Starting $($myinvocation.mycommand)"
        $splat = @{
            ErrorAction = "Stop"
        }

        if ($psboundparameters.containskey["Stream"]) {

            $splat.Add("Stream", $psboundparameters["Stream"])
        }
        else {
            $splat.Add("Stream", "tags")
        }
    } #begin

    Process {
        foreach ($item in $path) {
            $splat['Path'] = Convert-Path $item
            Write-Verbose "[$((Get-Date).TimeofDay) PROCESS] Getting tags stream $($splat['Stream']) from $item"
            Try {
                $info = Get-Content @splat
                [pscustomobject]@{
                    Path       = $splat.path
                    Tags = $info
                }
            }
            Catch {
                Write-Warning "The stream $($splat['Stream']) was not found on $path"
            }
        }
    } #process

    End {
        Write-Verbose "[$((Get-Date).TimeofDay) END    ] Ending $($myinvocation.mycommand)"
    } #end

} #close Get-TagStream

With this command, it is easy to find files based on a tag.

There's no end to how you could use alternate data streams, and you are welcome to use my code as a framework. My functions should be considered proof-of-concept and not production-ready.

Limitations

But before you get too excited, there are limitations to alternate data streams. This feature is only supported on Windows and NTFS formatted drives. If you copy a file with alternate data streams from one NTFS drive to another, the streams should also copy. But if you copy the file to a non-NTFS drive, you will lose the streams.

If you back up or archive files, you also might lose the alternate data streams. However, it is worth looking into settings. I use WinRar, and the application has a setting to save file streams.

saving streams with winrar

Finally, if you synchronize files with a cloud service like OneDrive or DropBox, expect to lose the alternate data streams. I have not been able to find any way to configure these services to include alternate data streams. If you know of a way, I'd love to hear it.

Summary

I hope you found this intriguing and got your gears churning to figure out how you can take advantage of this feature. As I hope you've seen, building PowerShell tooling around alternate data streams is not that difficult. If you find a use for ADS, I hope you'll let me know.

By the way, in addition to PowerShell, you can use the streams.exe utility from SysInternals to list and delete alternate data streams.

Have fun with this, and please test with non-production files. Enjoy.


Behind the PowerShell Pipeline

Share this:

  • Click to share on X (Opens in new window) X
  • Click to share on Facebook (Opens in new window) Facebook
  • Click to share on Mastodon (Opens in new window) Mastodon
  • Click to share on LinkedIn (Opens in new window) LinkedIn
  • Click to share on Pocket (Opens in new window) Pocket
  • Click to share on Reddit (Opens in new window) Reddit
  • Click to print (Opens in new window) Print
  • Click to email a link to a friend (Opens in new window) Email

Like this:

Like Loading...

Related

6 thoughts on “Friday Fun with PowerShell and Alternate Data Streams”

  1. Pingback: Friday Fun with PowerShell and Alternate Data Streams - The Lonely Administrator - Syndicated Blogs - IDERA Community
  2. Scott Crawford says:
    February 18, 2022 at 3:07 pm

    I’ve played with these for years, but never really noticed the -Stream parameter on the cmdlets you’ve mentioned. There are some intriguing ideas here, but they’re really a local-system-only type of feature since they’re so fragile. As mentioned, copying to something that doesn’t support them just silently deletes them. Perhaps surprisingly, this includes local git repositories.

    Fun fact, the Mark of the Web is stored in a stream called Zone.Identifier, which is how Windows knows you downloaded a file. Deleting that stream is an easy way to “Unblock” a bunch of files.

    Also, assuming you know the name of the stream you can reference it directly like so:
    c:\path\filename.ext:stream

    Which reminds me…I’ve recently written code under the assumption that a path will have a max of one colon…off to fix a corner case. 🙂

    1. Jeffery Hicks says:
      February 18, 2022 at 4:16 pm

      You are right about the Stream parameter. This is why I am always telling people to read and re-read help, even for commands they “think” they know. I didn’t write about it, but I spent hours working on a function to enumerate streams. Then I looked at help for Get-Item and the answer was right there! As for the zone identifier, I read somewhere that Microsoft prefers you still use Unblock-File.

      This is a niche solution definitely. But the Friday Fun articles are meant to be educational more than providing ready-to-run code. Thanks for reading.

  3. Scott Crawford says:
    February 21, 2022 at 12:37 pm

    Ahh yes, Unblock-File…I never think of that either. 🙂 And now we can create the complementary Block-File for fun and amusement.

    function Block-File {
    [CmdletBinding()]
    param (
    [Parameter(ValueFromPipeline)]
    [System.IO.FileInfo[]]$Path
    )

    process {
    foreach ($file in $Path) {
    Set-Content -Path $file.FullName -Stream Zone.Identifier -Value “[ZoneTransfer]`r`nZoneId=3”
    }
    }
    }

  4. Jim Witherspoon says:
    June 29, 2022 at 11:50 am

    Very interesting! Alternate Data Streams have long seemed to be in disfavor but they are in very wide use for many different purposes. If one problem is that bad things can be hidden in them, why not make file management tools that make them completely visible, manageable, and editable – as easily as the primary “unnamed” stream? If the streams can easily lost, why not create a mechanism where alternate streams are backed up and can be restored when they go missing? The Windows Property System has been made the preferred mechanism for metadata, and the primary stream is the preferred location, but this does not give users the ability to create their own custom fields – developers determine what metadata properties are available for a given file type. But alternate stream data can also be made available to users through the Windows Property System and Windows Search. Your scripts illustrate how each named stream could contain a single metadata field – so a particular metadata field could be accessed through the name of that stream. What if the user wants to attach many different user-defined fields to a file in this way? From a quick Google it seems that a very large number of named streams can be attached to a single file, so this would not be a problem. As it is, I often use file renaming tools to cram information into the file name that would be better put into named streams.

    1. Jeffery Hicks says:
      June 30, 2022 at 8:36 am

      I agree that there is a lot of utility to alternate data streams. The challenge is that the streams are really tied to NTFS and not the file. For example, if I zip up a file and later restore it, the ADS information is lost. Unless I use an archiving utility, like WinRar, that knows how to include data streams. In fact, now that you bring this up, I may need to revisit my backup tools to ensure I’m including ADS.

Comments are closed.

reports

Powered by Buttondown.

Join me on Mastodon

The PowerShell Practice Primer
Learn PowerShell in a Month of Lunches Fourth edition


Get More PowerShell Books

Other Online Content

github



PluralSightAuthor

Active Directory ADSI Automation Backup Books CIM CLI conferences console Friday Fun FridayFun Function functions Get-WMIObject GitHub hashtable HTML Hyper-V Iron Scripter ISE Measure-Object module modules MrRoboto new-object objects Out-Gridview Pipeline PowerShell PowerShell ISE Profile prompt Registry Regular Expressions remoting SAPIEN ScriptBlock Scripting Techmentor Training VBScript WMI WPF Write-Host xml

©2025 The Lonely Administrator | Powered by SuperbThemes!
%d