Field Notes: Reconstructing an Attacker’s Workflow
IntroductionDuring a recent incident, we recovered several PowerShell scripts the 2026-10-24 08:3:52 Author: dfir.ch(查看原文) 阅读量:25 收藏

Introduction

During a recent incident, we recovered several PowerShell scripts the attacker had left behind on disk. Instead of reconstructing every action from individual event log entries, file-system artefacts, and EDR telemetry, we could read some parts of the attacker’s workflow directly from their own code. The scripts covered several stages of post-compromise activity: command execution, credential harvesting, Active Directory reconnaissance, remote-access testing, and SMB share discovery.

Parts of this tradecraft overlap with activity Microsoft documented in its analysis of the TerminalFix campaign, a ClickFix variant that ultimately deploys a reverse tunnel into the victim network. Among other things, Microsoft described a persistent PowerShell loop that watches a local file for commands, executes them, and writes the results to another file.

In this Field Note, we will walk through the PowerShell artefacts we recovered and see what they tell us about the attacker’s workflow.

A File-Based Command Runner

Microsoft described the same basic mechanism: a persistent while ($true) loop watches a file for changes, executes newly written commands, and stores the resulting output in a second file. The recovered script monitors a file in %TEMP% and compares its LastWriteTime with the previously observed value. When the timestamp changes, the complete file contents are read and passed directly to Invoke-Expression (iex). Both standard output and error output are redirected into another file.

PowerShell scripts

Figure 1: Various PowerShell artifacts left on disk

Following the relevant code:

$watchFile = Join-Path $env:TEMP 'watchFile.txt'
$outputFile = Join-Path $env:TEMP 'outputFile.txt'
$lastWrite = $null

while ($true) {
    $currentWrite = (Get-Item $watchFile -ErrorAction SilentlyContinue).LastWriteTime
    if ($currentWrite -ne $lastWrite) {
        try {
            $content = Get-Content $watchFile -Raw -ErrorAction Stop
            try {
                & { iex $content } *>$outputFile
            } catch {
                $_ | Out-File $outputFile -Append
            }
        } catch {
            Start-Sleep -Seconds 1
        }
        $lastWrite = $currentWrite
    } else {
        Start-Sleep -Seconds 1
    }
}

Credential Harvesting

One of the more interesting PowerShell scripts left behind by the attacker presents the victim with a Windows credential prompt claiming that authentication is required. It repeatedly calls PromptForCredential() and requests credentials for a predefined user account.

Authentication prompt

Figure 2: Authentication prompt

The most relevant parts are these:

$targetUser = 'DOMAIN\user'

$cred = $host.UI.PromptForCredential(
    'Windows Security',
    "Enter your credentials for: $targetUser",
    $targetUser,
    ''
)

$plainUser = $cred.UserName
$plainPass = $cred.GetNetworkCredential().Password

PromptForCredential() returns a PSCredential object containing a SecureString, but the attacker immediately converts it back into a plaintext password using: $cred.GetNetworkCredential().Password. The script then performs two separate checks. First, it verifies that the supplied username matches the account the attacker is interested in:

if (
    $plainUser -ne $targetUser -and
    $plainUser -ne $targetUser.Split('\')[-1]
) {
    # Wrong account
    continue
}

Entering valid credentials for a different account is therefore not enough. If the username matches, the script tries to start a hidden PowerShell process using the supplied credentials:

Start-Process `
    -FilePath 'powershell.exe' `
    -Credential $cred `
    -ArgumentList 'exit' `
    -WindowStyle Hidden `
    -ErrorAction Stop

The attacker uses process creation as a credential-validation mechanism. A successful call indicates that Windows accepted the supplied credentials for this process-creation attempt. However, it does not necessarily prove that the credentials can be used to authenticate against other systems or access network resources.

Incorrect password

Figure 3: Login failed

The victim cannot simply get rid of the prompt by entering arbitrary credentials. The script continues until the victim supplies credentials for the expected account that successfully authenticate the user. Every attempted password is written to a log file in cleartext. The actual recovered file contained the attempted password in cleartext.

[2026-09-14 10:49:34] CANCELLED - No credentials entered
[2026-09-14 10:49:41] CANCELLED - No credentials entered
[2026-09-14 10:49:43] CANCELLED - No credentials entered
[2026-09-14 21:01:35] CANCELLED - No credentials entered
[2026-09-14 21:02:19] FAILED - Invalid password for: dfir\malmoeb | Password: dfir.ch

From an incident-response perspective, that file is obviously extremely sensitive. If such an artefact is recovered, any credentials contained in it have to be considered compromised regardless of whether the attacker’s later actions are visible.

There is an interesting lesson here: credential theft does not always require LSASS dumping, browser credential extraction, or a dedicated infostealer. Sometimes the attacker simply asks the user for the password. It also fits nicely with a talk I gave called “Cleartext Passwords and Where to Collect Them”: cleartext credentials still turn up in far more places than many investigators expect.

Active Directory Reconnaissance

The next script shifts from credential collection to network discovery. It starts by querying Active Directory through DirectoryServices.DirectorySearcher:

$ds = New-Object DirectoryServices.DirectorySearcher
$ds.Filter = '(&(sAMAccountType=805306369))'

$cs = $ds.FindAll() |
    ForEach-Object {
        $_.Properties['dnshostname']
    } |
    Where-Object {
        $_
    }

The sAMAccountType value 805306369 identifies computer accounts. The result is therefore a list of Active Directory computer objects for which the dNSHostName attribute is populated. This does not necessarily mean that every returned system is still active or reachable; stale computer objects may also be present.

The script then creates a runspace pool with up to 30 concurrent workers and executes the following query against every discovered computer:

Get-WmiObject `
    Win32_OperatingSystem `
    -ComputerName $v.computer `
    -ErrorAction Stop

Systems for which the WMI query succeeds are added to the final result: Write-Host "Local admin access on $($ok.Count) computers:" The script labels these systems as having “local admin access,” but that description is slightly misleading. What it actually proves is that the current security context can successfully perform the remote WMI/DCOM query against that system.

Local administrator privileges commonly provide that capability, but delegated WMI permissions and other configuration differences can produce the same result. Although successful remote WMI access identifies systems on which the current security context permits remote interaction, it does not necessarily demonstrate local administrator privileges or the ability to execute commands remotely.

The largest PowerShell script we recovered performed SMB discovery. It first attempts to obtain all computer objects from Active Directory. If that fails, it falls back to local IPv4 information and generates addresses across the corresponding /24 ranges. For every candidate system, it checks whether TCP/445 is reachable:

$q = New-Object Net.Sockets.TcpClient

$a = $q.BeginConnect(
    $c,
    445,
    $null,
    $null
)

if (-not $a.AsyncWaitHandle.WaitOne(500, $false)) {
    return
}

For reachable hosts, the script calls the native Windows NetShareEnum() API from netapi32.dll to enumerate SMB shares. It then tries to list each share:

$p = "\\$c\$($u.n)"

Get-ChildItem `
    $p `
    -Force `
    -ErrorAction Stop

If this succeeds, the share is considered accessible. The script additionally calls GetDiskFreeSpaceEx() to determine the size of the underlying volume and inspects the ACL in an attempt to classify access as Read, Read+Write, Modify, or FullControl.

That ACL calculation should not be interpreted as a perfect effective-permissions calculation. SMB access is influenced by both share permissions and NTFS permissions, and reproducing Windows access-check semantics by manually combining ACL entries is considerably more complicated than this script suggests. Sample output:

Hosts: 392 | shares: 44 | readable: 4

Computer        Share      SizeGB  Rights  Remark
--------        -----      ------  ------  ------
dfir.local      ADMIN$             Denied  Remoteverwaltung
dfir.local      C$                 Denied  Standardfreigabe
redteam.local   ADMIN$             Denied  Remoteverwaltung
redteamlocal    C$                 Denied  Standardfreigabe
dc03.local      ADMIN$             Denied  Remoteverwaltung
dc03.local      C$                 Denied  Standardfreigabe
dc03.local      NETLOGON    99.1    Read   Logon server share
dc03.local      SYSVOL      99.1    Read   Logon server share
...
abdc01.local    ADMIN$             Denied  Remote Admin
abdc01.local    C$                 Denied  Default share
abdc01.local    NETLOGON   149.5    Read   Logon server share
abdc01.local    SYSVOL     149.5    Read   Logon server share
...

One additional detail is worth noting: SizeGB does not represent the amount of data stored inside the individual share. The script uses GetDiskFreeSpaceEx( against the share path and therefore reports information about the underlying filesystem volume. This is why multiple shares hosted on the same volume can show identical values.

What These Scripts Tell Us

Looking at the scripts together provides a fairly clear progression. The attacker had a mechanism for executing arbitrary PowerShell commands, attempted to obtain credentials for a specific user, validated those credentials, enumerated Active Directory computer objects, tested remote WMI access, and mapped accessible SMB shares.

In other words, these were not isolated scripts. Together, these scripts reveal a post-compromise toolkit designed for command execution, credential harvesting, reconnaissance, and identification of accessible network resources. Their recovery alone does not establish when or whether each capability was used.

Hunting and Detection

PowerShell Script Block Logging is particularly valuable in a case like this. Event ID 4104 can preserve the actual PowerShell code being executed, including dynamically generated or decoded script blocks. Module Logging can provide additional visibility through Event ID 4103.

Rather than searching only for filenames or hashes, hunt for behavioural fragments from the recovered scripts. Examples include:

  • PromptForCredential
  • GetNetworkCredential().Password
  • DirectoryServices.DirectorySearcher
  • sAMAccountType=805306369
  • Get-WmiObject Win32_OperatingSystem -ComputerName
  • Net.Sockets.TcpClient
  • BeginConnect
  • NetShareEnum
  • GetDiskFreeSpaceEx

Some of these strings are perfectly legitimate on their own. Combinations of them, particularly when executed by PowerShell from an unusual user context or during an incident timeframe, are considerably more interesting.

Process and network telemetry provide another useful angle. Windows Security Event ID 4688, Sysmon Event ID 1, or equivalent EDR telemetry can reveal suspicious PowerShell execution and unusual parent-child relationships. Network telemetry can expose a PowerShell process connecting to large numbers of systems over TCP/445 or performing remote WMI/DCOM activity across many hosts.

Conclusion

None of the individual techniques is particularly exotic. That is exactly what makes the case interesting. The attacker assembled ordinary Windows and PowerShell functionality into a practical post-exploitation toolkit without relying on a large collection of external binaries. Fortunately for us, they also did not clean up particularly well.


Field Notes: Short blog posts about interesting techniques we encounter in the wild.


文章来源: https://dfir.ch/posts/field_note_attacker_workflow/
如有侵权请联系:admin#unsafe.sh