Skip to content

Instantly share code, notes, and snippets.

@YoraiLevi
Last active July 4, 2026 01:09
Show Gist options
  • Select an option

  • Save YoraiLevi/49da57da5c7bb4724ba3f4e4c694d748 to your computer and use it in GitHub Desktop.

Select an option

Save YoraiLevi/49da57da5c7bb4724ba3f4e4c694d748 to your computer and use it in GitHub Desktop.
Run the Windows OpenSSH service as your own user account — and see the SSH logon-session token change (Network -> Service); side effect: cmdkey/DPAPI/mapped drives work over SSH.

Run the Windows OpenSSH service as your own user account — and see the SSH logon-session token change

A follow-along runbook. Assumes the built-in Microsoft OpenSSH server is already installed and you can already log in over SSH with a key. Tested on Windows 11 / Server 2022, OpenSSH 9.x.


What this changes

By default the sshd service runs as LocalSystem. This guide changes it to run as your own user account. That one change alters the logon-session token your SSH sessions receive, and that in turn unlocks some things that don't otherwise work over SSH:

change:      sshd service identity   LocalSystem ──► .\youruser
   ↓
result:      your SSH session token   Network (type 3) ──► Service (type 5)
   ↓
side effect: a Service token carries a credential store + an unlocked DPAPI key, so —
             cmdkey can save credentials · net use /persistent sticks · DPAPI secrets read

The subject of this guide is the identity change (running sshd as a user). The credential/DPAPI behavior is a side effect of the token type you end up with — useful, but not the point. You'll verify the token type directly, before and after, so you can see exactly what changed.

Use this when one person owns the box and you want the sshd service to run under a specific user identity (whether for the Service-token side effects above, or just to stop running the daemon as LocalSystem). Do not if multiple different accounts must SSH into the same box — this locks the box to a single SSH account (see Caveats).


Prerequisites

  1. The account you SSH in as must be a local Administrator. Check that your account appears in the output:

    net localgroup Administrators

    (Reason: after we trim one privilege in Step 5, the remaining privileges sshd needs — Backup/Restore/Impersonate — are ones a local admin already holds through the Administrators group. A non-admin account would need those granted too.)

  2. That account needs a real Windows password — not a PIN, not a passwordless Microsoft-account login. The service will store and log on with this password.

  3. Open an elevated PowerShell — press Win+X → Terminal (Admin) (or Windows PowerShell (Admin)) and accept the UAC prompt. Every command below that changes the system is run in this elevated shell.

  4. Have console/physical access as a safety net (or your hypervisor's VM console). If sshd fails to start after the change, that's how you recover — see Rollback.

The values you'll substitute — how to get them, and how to check they're valid

Three placeholders appear below. Read the first two straight from the elevated terminal you're already in — they resolve to your current account, which is almost certainly the one you want to run sshd as:

whoami           # e.g.  desktop-a85n5lo\devic   — this "COMPUTER\user" form is what Step 3 wants
$env:USERNAME    # e.g.  devic                    — the ".\user" short form is what Step 4 wants
hostname         # e.g.  DESKTOP-A85N5LO          — just the computer name, if you need it alone
  • youruser → your account name ($env:USERNAME, e.g. devic)
  • THISPC → this computer's name (hostname, e.g. DESKTOP-A85N5LO)
  • YourWindowsPassword → that account's real Windows password (what you'd type at the lock screen — not a PIN)

THISPC\youruser (Step 3) and .\youruser (Step 4) name the same local account — the NTAccount API in Step 3 needs the computer-name form (which whoami prints for you), while sc.exe accepts the .\ shorthand. Neither is a typo.

Validate the account before you start — this conversion only works for a local, admin account that has a real password. These three checks all target your current user automatically:

# 1) Must be a LOCAL account — expect:  Local
#    (If it prints MicrosoftAccount or AzureAD, a plain service password won't log it on: use/create a local admin account instead.)
(Get-LocalUser $env:USERNAME).PrincipalSource

# 2) Must be a local Administrator — your username should appear in the output (nothing printed = not an admin):
net localgroup Administrators | Select-String $env:USERNAME

# 3) Should have a real password set — a date = good; blank = set one now with:  net user $env:USERNAME *
(Get-LocalUser $env:USERNAME).PasswordLastSet

If all three pass, you can follow the steps using your current account with no hand-editing: paste whoami's output wherever a step shows THISPC\youruser, and use .\$env:USERNAME wherever it shows .\youruser. Steps 3 and 4 point this out inline.


Verify FIRST: what logon token do your SSH sessions get today?

Run this before you change anything and note the result — it's your baseline. It opens a fresh SSH session and prints that session's group memberships; the logon type appears as a group in the list.

ssh youruser@localhost whoami /groups

In the output, find the NT AUTHORITY\… line that names a logon type:

  • now (sshd = LocalSystem): NT AUTHORITY\NETWORK, SID S-1-5-2 → a Network logon (type 3) — no credential store, DPAPI locked.
  • after Step 6 the same line will read NT AUTHORITY\SERVICE, SID S-1-5-6 (Service, type 5).

Prove the side effect while you're here — the add's own output is the whole signal:

ssh youruser@localhost cmdkey /add:__x /user:x /pass:y
ssh youruser@localhost cmdkey /delete:__x

Right now the first line prints "Credentials cannot be saved from this logon session."; after the change it prints "Credential added successfully."

Why these verification commands carry no quotes — and yours shouldn't either. Everything inside ssh host "…" is parsed twice: once by your local shell (PowerShell, cmd, or Git Bash) and again by the remote SSH shell (cmd.exe on a stock install, PowerShell if you set DefaultShell). Nested quotes like ssh host "… /C:\"NT AUTHORITY\NETWORK\" …" therefore break differently on every combination — don't write them. The robust pattern, used throughout this guide: keep the remote command to plain, space-separated arguments with no quotes and no | & ;, and do any filtering locally, after the ssh call, where only your own shell is involved. To filter the group list, pipe it locally: ssh youruser@localhost whoami /groups | findstr SERVICE (PowerShell or cmd) or … | grep SERVICE (Git Bash) — a single word, still unquoted.


Step 1 — Confirm the current service state

sc.exe qc sshd | Select-String SERVICE_START_NAME

Expected: SERVICE_START_NAME : LocalSystem. This is the default we're about to change. (sc.exe qc = query config for a service. Steps 1–6 run in your local elevated PowerShell, so Select-String is fine here — only the SSH token/verify commands use findstr.)


Step 2 — See which privileges sshd requires

The Service Control Manager (SCM) refuses to start sshd unless the account holds every privilege sshd's service manifest declares. List them:

sc.exe qprivs sshd

Expected output (order may vary):

SeAssignPrimaryTokenPrivilege
SeTcbPrivilege
SeBackupPrivilege
SeRestorePrivilege
SeImpersonatePrivilege

Note this list — you'll re-use it, minus SeTcbPrivilege, in Step 5. SeTcbPrivilege ("act as part of the operating system") is the problem child: it's granted to no account by default, so your admin account won't have it, and we will not grant it (it's a dangerous, SYSTEM-equivalent privilege). Instead we'll remove it from what sshd requires.


Step 3 — Grant your account the two rights it's missing

Your account needs two rights it doesn't have by default:

  • SeAssignPrimaryTokenPrivilege ("Replace a process level token") — sshd uses it to spawn your shell.
  • SeServiceLogonRight ("Log on as a service") — required of any account a service runs as.

Paste this elevated PowerShell block. Edit only the first line. It grants both rights additively (adds your account without disturbing anyone else's rights) and is safe to re-run:

$acct = 'THISPC\youruser'   # EDIT to your COMPUTER\user  —  or use your current account:  $acct = whoami

$code = @'
using System;
using System.Runtime.InteropServices;
using System.Security.Principal;
public static class Lsa {
  [StructLayout(LayoutKind.Sequential)] struct US { public ushort Length; public ushort MaximumLength; public IntPtr Buffer; }
  [StructLayout(LayoutKind.Sequential)] struct OA { public int Length; public IntPtr RootDirectory; public IntPtr ObjectName; public uint Attributes; public IntPtr SecurityDescriptor; public IntPtr SecurityQualityOfService; }
  [DllImport("advapi32.dll", SetLastError=true)] static extern uint LsaOpenPolicy(IntPtr S, ref OA oa, uint a, out IntPtr h);
  [DllImport("advapi32.dll", SetLastError=true)] static extern uint LsaAddAccountRights(IntPtr h, byte[] sid, US[] r, uint c);
  [DllImport("advapi32.dll")] static extern uint LsaClose(IntPtr h);
  [DllImport("advapi32.dll")] static extern int LsaNtStatusToWinError(uint s);
  static US S(string s){ return new US{ Length=(ushort)(s.Length*2), MaximumLength=(ushort)(s.Length*2+2), Buffer=Marshal.StringToHGlobalUni(s) }; }
  public static void Grant(string account, string right){
    var sid=(SecurityIdentifier)new NTAccount(account).Translate(typeof(SecurityIdentifier));
    var sb=new byte[sid.BinaryLength]; sid.GetBinaryForm(sb,0);
    var oa=new OA(); IntPtr h;
    uint st=LsaOpenPolicy(IntPtr.Zero, ref oa, 0x000F0FFF, out h);   // access mask needed to add account rights
    if(st!=0) throw new Exception("LsaOpenPolicy "+LsaNtStatusToWinError(st));
    try { st=LsaAddAccountRights(h, sb, new US[]{ S(right) }, 1); if(st!=0) throw new Exception("LsaAddAccountRights "+LsaNtStatusToWinError(st)); }
    finally { LsaClose(h); }
  }
}
'@
if (-not ('Lsa' -as [type])) { Add-Type -TypeDefinition $code }   # guard: re-running won't error
[Lsa]::Grant($acct,'SeAssignPrimaryTokenPrivilege')
[Lsa]::Grant($acct,'SeServiceLogonRight')
'Granted SeAssignPrimaryTokenPrivilege + SeServiceLogonRight to ' + $acct

Why a code block instead of a one-liner? Windows has no built-in command to grant a privilege additively; the clean, safe way is the LsaAddAccountRights API, which this wraps. It only adds — it can't strip anyone else's rights (unlike editing security policy by hand, which replaces) — and re-granting a right you already hold just returns success.

GUI alternative: secpol.mscLocal PoliciesUser Rights Assignment → open "Replace a process level token" and "Log on as a service", add your account to each.


Step 4 — Point the sshd service at your account

sc.exe config sshd obj= ".\youruser" password= "YourWindowsPassword"

Edit: youruser and YourWindowsPassword. The spaces after obj= and password= are mandatory — that's sc.exe's peculiar syntax; omit them and it fails. .\youruser means "the local account youruser". You should see [SC] ChangeServiceConfig SUCCESS.

Current-user shortcut (run in the same elevated PowerShell — it fills in your username; still type your real password):

sc.exe config sshd obj= ".\$env:USERNAME" password= "YourWindowsPassword"

⚠️ The password is on this command line, so it lands in your PowerShell history. To avoid that: run Clear-History afterward, or use the services.msc GUI instead (OpenSSH SSH Server → Properties → Log On → This account), whose password box isn't logged.


Step 5 — Remove SeTcbPrivilege from what sshd requires

If you tried to start sshd now it would fail (the SCM wants SeTcbPrivilege, which your account correctly lacks). Rather than grant that dangerous privilege, redefine sshd's required set to the Step-2 list minus SeTcbPrivilege — the same-user code path never actually uses it:

sc.exe privs sshd SeAssignPrimaryTokenPrivilege/SeBackupPrivilege/SeRestorePrivilege/SeImpersonatePrivilege

Note: privileges are slash-separated, and this replaces the required list with exactly what you type — include every one from Step 2 except SeTcbPrivilege. If your Step 2 output differed, mirror your list here (minus SeTcbPrivilege). Expect [SC] ChangeServiceConfig2 SUCCESS.


Step 6 — Restart sshd

⚠️ Lockout warning. If you are connected over SSH right now, restarting sshd cuts your own session and — if the service fails to come back — locks you out. Do the restart from a local console / VM console, or a scheduled task that outlives the SSH session. If you're at the machine locally, it's safe.

net stop sshd
net start sshd

net start prints success or the failure reason synchronously. Expected: "The OpenSSH SSH Server service was started successfully." If it fails, jump to Troubleshooting.


Step 7 — Verify AFTER: confirm the token changed

7a — the service now runs as your account (the | here is your local shell filtering ssh's output — nothing is quoted for the remote):

ssh youruser@localhost sc.exe qc sshd | findstr SERVICE_START_NAME

SERVICE_START_NAME : .\youruser (Git Bash: use grep instead of findstr.)

7b — the token flipped from Network to Service (the same command as the baseline):

ssh youruser@localhost whoami /groups

→ the NT AUTHORITY\… line now reads SERVICE / S-1-5-6 instead of NETWORK / S-1-5-2. This is the actual proof — your SSH session is now a Service logon (type 5).

7c — the side effect: cmdkey now works over SSH:

ssh youruser@localhost cmdkey /add:__verify /user:x /pass:y
ssh youruser@localhost cmdkey /delete:__verify

→ the first line prints "CMDKEY: Credential added successfully." (before: "cannot be saved from this logon session.")

If 7b shows SERVICE, the change worked. It survives reboots automatically (the service config and stored password persist).


Rollback (undo everything)

sc.exe config sshd obj= LocalSystem password= ""
sc.exe privs sshd SeAssignPrimaryTokenPrivilege/SeTcbPrivilege/SeBackupPrivilege/SeRestorePrivilege/SeImpersonatePrivilege
net stop sshd
net start sshd

Returns sshd to LocalSystem and restores the original required-privilege list. (The two rights granted in Step 3 are harmless to leave; remove them via secpol.msc if you want a clean slate.) Restart from the console if you're remote. Re-run the baseline token check to confirm you're back to NETWORK / S-1-5-2.


Troubleshooting

net start failed. Find the real reason (local elevated PowerShell). The SCM logs these under the service's display name "OpenSSH SSH Server", not "sshd" — so match OpenSSH:

Get-WinEvent -FilterHashtable @{LogName='System'; Id=7000,7038,7009,7011} -MaxEvents 10 |
  Where-Object Message -match 'OpenSSH|sshd' | Format-List TimeCreated, Id, Message
  • "A privilege that the service requires … does not exist in the service account configuration" (Event 7000) → you skipped Step 5, or your Step-5 list didn't match Step 2. Re-run Step 5 with your exact sc.exe qprivs sshd output minus SeTcbPrivilege.
  • "… due to a logon failure" (7000/7038) → the Step-4 password is wrong, or Step 3 didn't grant Log on as a service. Re-check both.

sshd starts but 7b still shows NETWORK: the service is still LocalSystem — re-check Step 4 (sc.exe qc sshd).


Caveats — know these before you commit

  • Single-account lock (by design). After this change, only youruser can SSH into this box. Any other account's key connection is refused ("Permission denied"). Baked into how sshd builds the token when it isn't LocalSystem. Fine for a single-owner machine; a blocker if you need multiple SSH users.
  • Key-only, please. Set PasswordAuthentication no in C:\ProgramData\ssh\sshd_config (then restart sshd). A password SSH login takes a different code path and comes back as a network logon (type 8) — silently giving you the Network-type behavior again for that session. Key auth is what yields the Service token.
  • The account's password lives in the SCM's protected store. Anyone already SYSTEM/admin on the box can extract it — negligible where you're the sole admin, real on a shared box. If you change the account's Windows password later, re-run Step 4.
  • Where your keys live. Works whether authorized_keys is per-user (C:\Users\youruser\.ssh\authorized_keys) or, for admin accounts on a stock install, the shared C:\ProgramData\ssh\administrators_authorized_keys. When sshd runs as your user it can read either. Don't add a custom explicit ACE to the host keys — sshd's strict permission check rejects that; the default SYSTEM/Administrators ACLs already let your admin account read them.

Why the token changes (one paragraph)

A Windows logon session is stamped with a type (Interactive, Network, Service, Batch…), and two capabilities hang off that type: a credential store (what cmdkey writes to) and an unlocked DPAPI master key (derived from a password). Key-auth SSH gives you a Network logon — neither capability. When sshd runs as your user, the service itself starts under a Service logon (the SCM authenticated it with your password at startup), and for a same-user key connection sshd simply hands your session a copy of that Service token. A Service logon has a credential store and an unlocked DPAPI — which is why, purely as a consequence of the identity change, cmdkey, net use /persistent, and DPAPI reads begin working over SSH.


Appendix — Windows logon-session tokens: what each allows, and when to prefer which

Every time an account authenticates, Windows creates a logon session and tags it with a logon type that records how the login happened. That type isn't cosmetic — it decides which capabilities the session's access token carries. These are the types you'll actually meet, what each permits, and the reasoning behind choosing one.

Logon type You get it from Save creds (cmdkey/CredWrite) DPAPI unlocked Act as you to a 2nd machine Reusable password/creds sit in memory (LSASS) Prefer it when
Interactive (2) Console / RDP login; LogonUser(…INTERACTIVE) + password ✅ (has TGT / cached creds) Yes A human is actually working in the session, or a process needs the full "act as me" context — local secrets and outbound network
Network (3) SMB / IIS integrated auth / SSH public-key (via S4U) ❌ (no password given) No You only need to prove who the caller is for an authorization check — and you want the session to hold nothing reusable
NetworkCleartext (8) SSH password auth; IIS Basic auth ❌ (still network-class) ✅ (password present) Yes A network service must reach a further hop as the user but can't use Kerberos delegation — you accept the password-in-memory cost
Service (5) A service the SCM starts under an account (what this guide gives your SSH session) Yes A long-running daemon should run under one fixed identity with that account's full context
Batch (4) Scheduled Task with stored credentials Yes Unattended automation that must save/read credentials but shouldn't change the SSH server or hold an interactive session
NewCredentials (9) runas /netonly (local ✅) (local ✅) as the supplied creds the supplied creds You want to keep your local identity but use different credentials for outbound network only

The tradeoff in one line: isolation vs. capability.

  • Network (3) is the most isolated — it carries no password, no credential store, no reusable creds in memory. That's precisely why it's the default for remote services (SMB, and SSH key auth): if that session is compromised, the attacker gets no password to crack, no DPAPI secrets, and no way to pivot to another machine as you. The "limitations" this guide works around (cmdkey fails, DPAPI locked, no second hop) are the flip side of a deliberate security property, not an accident.
  • Interactive / Service / Batch / NetworkCleartext all keep a password around (that's how they unlock the credential store and DPAPI). They're more capable — but a compromise of one of these sessions is worth more to an attacker, because your reusable credentials are sitting in LSASS to be stolen (this is the whole premise of tools like Mimikatz). More power, wider blast radius.

So the choice is: how much standing credential exposure will you trade for how much convenience?

  • Single-owner box, you want a live SSH shell that saves creds and maps drives → Service (5) via this guide is a fine trade.
  • You only need to seed a credential once, or you're on a shared / hardened box → keep the isolated Network (3) SSH session and do the credential work in a separate Batch (4) scheduled task instead of upgrading the whole session — the secret lives in one narrow place, not in every SSH login.
  • You genuinely need a human-grade Interactive (2) session (full desktop-equivalent context) → that's what RDP/console is for; forcing it onto a network daemon is usually a security smell.

One nuance — the "double-hop." Even a credential-bearing token can't always reach a second server. A logon built from NTLM (which local-account password logons are) has no Kerberos ticket-granting ticket, so \\other-server\share from inside the session can still fail with an access error, even though cmdkey locally succeeded. Reaching a second hop as yourself needs either Kerberos with delegation configured, or explicitly re-supplied credentials (the NewCredentials//netonly path). Don't assume "credential store works" implies "I can now reach every network resource."

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment