In the previous post I built a cellular APN watchdog: a PowerShell script on disk plus a scheduled task running it as SYSTEM every five minutes. It works. Now look at what I actually built, from an attacker’s point of view: a file that, when modified, executes arbitrary code as SYSTEM on a timer. Every monitoring script, remediation script, and fleet “proactive remediation” has this shape. If you would not leave a SYSTEM shell lying around, do not leave a writable SYSTEM script lying around either.

This post is the hardening companion to that watchdog. The techniques apply to any scheduled PowerShell script that runs elevated.

Quick answer

Four layers, cheapest first:

  1. NTFS ACLs. Strip inheritance on C:\ProgramData\CellWatch and watchdog.ps1; grant SYSTEM Full Control and Administrators Read + Execute. This stops every non-admin: curious users, user-level malware, accidental edits.
  2. Code signing with AllSigned enforced. A modified script no longer runs — it fails closed with a signature error instead of executing attacker code as SYSTEM.
  3. Audit object access. Log every write to the file (Event 4663) and forward it to your SIEM or RMM.
  4. Central deployment. Intune, GPO, or your RMM owns the file; local changes get overwritten on the next sync. The copy on disk is not the source of truth.

One honest caveat before the steps: nothing on the box fully stops a determined local administrator — they can take ownership of any file. The goal is to stop everyone else cold, and to make admin-level tampering loud, visible, and non-persistent.

Threat model: know who you are defending against

  • The curious user. Has the laptop, no admin rights, likes to poke around C:\ProgramData. Stopped by ACLs.
  • User-level malware. Runs as the logged-in user and hunts for writable scripts that execute at higher privilege — a classic privilege-escalation path. Stopped by ACLs.
  • The malicious insider with local admin, or malware that already escalated. Can take ownership of any file, disable auditing, repoint the task. Cannot be prevented on-box. Detected (auditing, central hash checks) and undone (central redeploy).
  • The accidental admin. You, at 2am, hand-editing the file and shipping a typo fleet-wide. Stopped by central deployment and signing. In my experience this row fires more often than the other three combined.

Each layer below maps to one of these rows. Anyone selling a single switch that “prevents tampering” is selling you layer 1 and hoping you never ask about layer 3.

Step 1: Lock the file and its directory with NTFS ACLs

The file first. Remove inherited permissions, then grant the minimum:

icacls C:\ProgramData\CellWatch\watchdog.ps1 /inheritance:r
icacls C:\ProgramData\CellWatch\watchdog.ps1 /grant:r SYSTEM:(F)
icacls C:\ProgramData\CellWatch\watchdog.ps1 /grant:r Administrators:(RX)

Then the directory — this is the part people skip. Deleting a file, or replacing it with a same-named impostor, is governed by rights on the parent directory as much as on the file. If the directory stays writable, an attacker who cannot edit watchdog.ps1 can delete it and drop a new file with the same name.

icacls C:\ProgramData\CellWatch /inheritance:r
icacls C:\ProgramData\CellWatch /grant:r SYSTEM:(F)
icacls C:\ProgramData\CellWatch /grant:r Administrators:(RX)

Verify with icacls C:\ProgramData\CellWatch\watchdog.ps1 — you should see exactly two access entries, SYSTEM:(F) and BUILTIN\Administrators:(RX), and nothing else.

Two consequences to accept deliberately:

  • The scheduled task runs as SYSTEM, so it keeps full access: it reads the script and writes its log. Nobody else can write anything in that directory.
  • Future you cannot edit the file either without taking ownership first (takeown /f C:\ProgramData\CellWatch\watchdog.ps1 /a). That friction is intentional. Script updates should travel through your deployment pipeline, not through a 2am RDP session. If you must edit locally: take ownership, make the change, re-sign (Step 2), re-lock.

Step 2: Sign the script and enforce AllSigned

ACLs stop modification by non-admins. Signing handles the scarier case: the script gets modified anyway — stolen admin credentials, an imaging mistake, a bad deploy — and now the scheduled task is about to run attacker-controlled code as SYSTEM. You want that run to fail loudly instead of succeeding silently.

Get a code-signing certificate. In a domain, issue it from your internal CA via AD CS. For a lab, a self-signed certificate works if you push it into Trusted Publishers on every target (Group Policy: Computer Configuration, Windows Settings, Security Settings, Public Key Policies, Trusted Publishers).

Sign with a timestamp, so the signature outlives the certificate:

$cert = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert | Select-Object -First 1
Set-AuthenticodeSignature -FilePath C:\ProgramData\CellWatch\watchdog.ps1 `
    -Certificate $cert `
    -TimestampServer http://timestamp.digicert.com

Then enforce it on the exact command line the task runs — not via the machine-wide execution policy, but in the task action itself:

powershell.exe -NoProfile -ExecutionPolicy AllSigned -File "C:\ProgramData\CellWatch\watchdog.ps1"

Now tamper with a lab copy and watch what happens: PowerShell refuses to run it, the task’s Last Run Result shows a failure, and — because the watchdog log goes stale — your monitoring fires. That is fail closed: a visible outage instead of a silent SYSTEM compromise.

The honest caveat, because someone will raise it: -ExecutionPolicy is not a security boundary. powershell -ExecutionPolicy Bypass -File evil.ps1 runs anything. Signing does not stop an attacker from running code; it stops your scheduled task from running tampered code as SYSTEM without anyone noticing. Combined with Step 3, tampering becomes an event you see within minutes.

Step 3: Audit writes and alert on them

Enable the File System audit subcategory (elevated):

auditpol /set /subcategory:"File System" /success:enable /failure:enable

Then put an audit rule (SACL) on the script — log every write, delete, permission change, or ownership grab by anyone:

$acl = Get-Acl C:\ProgramData\CellWatch\watchdog.ps1 -Audit
$rule = New-Object System.Security.AccessControl.FileSystemAuditRule(
    "Everyone", "Write,Delete,ChangePermissions,TakeOwnership",
    "None", "None", "Success")
$acl.AddAuditRule($rule)
Set-Acl C:\ProgramData\CellWatch\watchdog.ps1 -AclObject $acl

A tamper attempt now lands in the Security log as Event 4663 (“An attempt was made to access an object”) with WriteData in the access list. Forward it with Windows Event Forwarding or your RMM and alert on it. Permission changes log Event 4670, which catches the take-ownership-then-edit sequence from the threat model above.

Step 4: Protect the scheduled task itself

Hardening the script while ignoring the task is locking the front door and leaving the window open. An attacker who cannot touch watchdog.ps1 can simply repoint the task action at another script — no file tampering required.

  • Standard users cannot create or modify tasks in the Task Scheduler root folder by default. Verify nobody loosened that.
  • Watch the TaskScheduler Operational log (Microsoft-Windows-TaskScheduler/Operational) for Event 140 (task updated) and Event 141 (task deleted) on the Cellular APN Watchdog task. Task tampering is the quieter path, and it is the one attackers actually prefer.
  • Keep the exported task XML next to the signed script in your deployment source. If the task drifts, re-importing the known-good XML repairs it — the same “source of truth” idea as the next step.

Step 5: Make the deployment the source of truth

Steps 1–4 are enough for one machine. For a fleet, add the layer that makes local tampering non-persistent: the script on disk is a copy, and the deployment system owns the original.

  • Ship the signed script and the task XML via Intune (Win32 app or Proactive Remediation), a GPO Files preference, or your RMM.
  • For Proactive Remediations, the detection script is a hash comparison:
$expected = '<sha256 of the signed script>'
$actual = (Get-FileHash C:\ProgramData\CellWatch\watchdog.ps1 -Algorithm SHA256).Hash
if ($actual -ne $expected) { exit 1 }  # non-compliant: remediation re-copies the file
  • The remediation re-copies the signed script, re-imports the task XML, and re-applies the ACLs.

Now the threat model closes: non-admins cannot touch the file (Step 1), any modification breaks the signature and fails loudly (Step 2), every attempt is logged (Step 3), the task itself is watched (Step 4), and anything that somehow lands gets overwritten on the next sync cycle (Step 5). Admin-level tampering is reduced from “persistent compromise” to “an incident you see, and that heals itself.”

Pitfalls

  • A self-signed certificate must be in Trusted Publishers on every target, or AllSigned rejects your own script fleet-wide. Push the cert with GPO before you enforce the policy.
  • Always timestamp the signature. An untimestamped signature dies with the certificate, and then every watchdog in the fleet fails closed at expiry — a self-inflicted outage on a timer.
  • Do not claim signing “prevents code execution.” It prevents your task from silently running tampered code as SYSTEM. Precision matters when someone audits this.
  • Test the failure mode on a lab machine: tamper a copy, confirm the task errors, confirm your monitoring fires. A control you have not tested is a hope.
  • The log directory lives under the same locked path, and that is fine — the task runs as SYSTEM, and SYSTEM retains Full Control.

Should you compile the script to an exe? Probably not — at least not for tamper protection. Tools like PS2EXE (the open-source standard, maintained as MScholtes/PS2EXE) do not truly compile PowerShell: they embed your script inside a launcher executable that hands it back to the PowerShell engine at runtime. The script is recoverable in minutes with strings or a .NET decompiler, so this is obfuscation, not compilation. It stops notepad-level editing — which NTFS ACLs already stop — while adding real costs: antivirus heuristics frequently flag PS2EXE output (malware authors abuse it, and a quarantined watchdog is worse than a tampered one), debugging gets harder, and every update requires a rebuild. Signing the exe gives the same tamper-evidence as signing the ps1, so you gain nothing on integrity either. Compile to an exe when you need a double-clickable tool for end users; for a SYSTEM scheduled watchdog, keep the signed ps1. If you genuinely need unreadable code, rewrite the logic in C# — but against the threat model in this post, that is overengineering.

What about embedding the script in a Go binary? It can still be cracked — the only question is how long it takes. A plain go:embed leaves the script in the binary’s data section, readable with strings in seconds. Encrypting it raises the bar to hours: the decryption key ships inside the same binary, and a skilled reverser will extract it. Two runtime realities also work against you: Go cannot host the .NET PowerShell engine, so it must spawn powershell.exe, whose content passes AMSI in plaintext — and on machines with Script Block Logging enabled, the decrypted script lands in the event log (Event 4104) verbatim. Rewriting the watchdog logic in Go, with no script at all, is genuinely harder to reverse — but hard is not impossible. Go’s real advantages over PS2EXE are operational, not security-related: far fewer antivirus false positives, a single static binary, no dependency on the local PowerShell version. And remember the distinction that matters here: this watchdog needs integrity (signing), not secrecy — the APN string is visible on the machine via netsh anyway. A signed Go exe and a signed ps1 are equivalent on integrity.

Summary checklist

  • C:\ProgramData\CellWatch and watchdog.ps1: inheritance stripped, SYSTEM:(F), Administrators:(RX)
  • Script signed with a timestamped certificate from a trusted issuer
  • Task action pins -ExecutionPolicy AllSigned
  • File System auditing on the script; Event 4663 forwarded and alerted
  • TaskScheduler Operational log watched for Events 140/141 on the watchdog task
  • Fleet: script and task XML deployed from a central source with hash-based drift remediation