Skip to content

Commit 6ccf4cf

Browse files
fix(sandbox): refuse a squatted account name and clean up partial provisioning
Two problems in the provisioning path, both raised in review. The account name is derived from a workspace hash rather than discovered, so it can be occupied by a local account that has nothing to do with Zero, whether by coincidence or because somebody put it there. Provisioning treated "NetUserAdd says it exists" as "this is ours", reset the account's password, added it to the managed group and adopted it. That is a stranger's account taken over during an elevated setup, on the strength of a name matching a pattern we generate ourselves. Ownership is now proven from the comment provisioning stamps before anything is touched, and a name held by an account Zero did not create fails with a typed collision error instead of being adopted. Second, a failure anywhere after the account existed left it behind. The rollback the setup path installs is only built once provisioning has returned successfully, so nothing could undo a failure between creating the account and storing its secret; the account, and possibly its granted logon rights, simply stayed. Provisioning now unwinds what the run actually did, in reverse, on every failure path. Scoped to what THIS run created, deliberately. An account that already existed and belongs to Zero is a working principal from an earlier setup, so deleting it because a later run failed would turn a partial failure into a total one. For that case the repair is dropping the stored secret instead: this run reset the password, so the secret no longer matches, and absent beats stale because the command path treats a missing secret as "not provisioned" and falls back rather than failing. The ownership gate is asserted against real accounts every Windows install carries, which needs no privilege because it only has to establish that they are not ours. Classifying everything as managed makes it fail.
1 parent fbe340b commit 6ccf4cf

3 files changed

Lines changed: 160 additions & 15 deletions

File tree

‎internal/sandbox/windows_identity_runtime_windows.go‎

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,24 +131,63 @@ func windowsSandboxPrincipalToken(config WindowsSandboxCommandConfig) (windows.T
131131
// therefore cleaned up, rather than an account nothing holds the secret for.
132132
func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) (windowsSandboxIdentity, error) {
133133
key := windowsSandboxWorkspaceKey(config.WorkspaceRoots)
134-
identity, password, err := provisionWindowsSandboxIdentity(key)
134+
identity, password, created, err := provisionWindowsSandboxIdentity(key)
135+
136+
// Undo whatever this run actually did, in reverse, on any failure after the
137+
// account exists. Without it a failure between creating the account and
138+
// storing its secret left the account behind with no caller able to remove
139+
// it: the rollback the setup path installs is only built once this function
140+
// has returned successfully.
141+
//
142+
// Scoped to what THIS run created on purpose. An account that already existed
143+
// and belongs to Zero is a working principal from an earlier setup, and
144+
// deleting it because a later run failed would turn a partial failure into a
145+
// total one.
146+
rightsGranted := false
147+
secretWritten := false
148+
secretPath := ""
149+
undo := func() {
150+
if secretWritten && secretPath != "" {
151+
// Dropping the secret is also the repair for a pre-existing account
152+
// whose password this run reset: the stored secret no longer matches,
153+
// and absent beats stale, since the command path treats a missing
154+
// secret as "not provisioned" and falls back rather than failing.
155+
_ = removeWindowsSandboxSecret(secretPath)
156+
}
157+
if identity.SID != nil && rightsGranted {
158+
_ = revokeWindowsSandboxLogonRights(identity.SID)
159+
}
160+
if created {
161+
_ = removeWindowsSandboxIdentity(identity.Username)
162+
}
163+
}
164+
135165
if err != nil {
166+
// provisionWindowsSandboxIdentity can fail after creating the account, so
167+
// this path needs the same cleanup even though nothing below ran.
168+
undo()
136169
return windowsSandboxIdentity{}, err
137170
}
138171
if err := grantWindowsSandboxLogonRights(identity.SID); err != nil {
172+
undo()
139173
return windowsSandboxIdentity{}, err
140174
}
141-
secretPath, err := windowsSandboxSecretPath(config.SandboxHome, identity.Username)
175+
rightsGranted = true
176+
secretPath, err = windowsSandboxSecretPath(config.SandboxHome, identity.Username)
142177
if err != nil {
178+
undo()
143179
return windowsSandboxIdentity{}, err
144180
}
145181
// The secret is rewritten every run so it stays in step with the account.
146182
// provisionWindowsSandboxIdentity guarantees the password it returns is the
147183
// account's real one, resetting it explicitly when the account already
148-
// existed, so this write is always storing something that can log on.
184+
// existed and belongs to Zero, so this write is always storing something that
185+
// can log on.
149186
if err := writeWindowsSandboxSecret(secretPath, password); err != nil {
187+
undo()
150188
return windowsSandboxIdentity{}, err
151189
}
190+
secretWritten = true
152191
return identity, nil
153192
}
154193

‎internal/sandbox/windows_identity_windows.go‎

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ var (
7777
procNetLocalGroupAddMembers = netapi32.NewProc("NetLocalGroupAddMembers")
7878
procNetUserDel = netapi32.NewProc("NetUserDel")
7979
procNetUserSetInfo = netapi32.NewProc("NetUserSetInfo")
80+
procNetUserGetInfo = netapi32.NewProc("NetUserGetInfo")
81+
procNetApiBufferFree = netapi32.NewProc("NetApiBufferFree")
8082
)
8183

8284
// userInfo1 mirrors USER_INFO_1. Field order and widths must match the Win32
@@ -317,6 +319,49 @@ func addWindowsSandboxUserToGroup(username string) error {
317319
return netAPIStatus("NetLocalGroupAddMembers", status, errorMemberInAlias)
318320
}
319321

322+
// errWindowsSandboxNameCollision reports that the derived account name is taken
323+
// by a local account Zero did not create. Setup refuses rather than adopting it.
324+
var errWindowsSandboxNameCollision = errors.New("a local account with Zero's derived sandbox name already exists and was not created by Zero")
325+
326+
// windowsSandboxUserIsManaged reports whether a local account is one Zero
327+
// created, by reading back the comment provisioning stamps on it.
328+
//
329+
// This is the gate on adopting an existing account. The name is derived, not
330+
// discovered, so an account can be sitting on it for reasons that have nothing
331+
// to do with Zero, and taking it over means resetting a stranger's password.
332+
//
333+
// A missing account is not managed rather than an error, so callers can use this
334+
// as a plain question without special-casing absence.
335+
func windowsSandboxUserIsManaged(username string) (bool, error) {
336+
name, err := windows.UTF16PtrFromString(username)
337+
if err != nil {
338+
return false, err
339+
}
340+
var buffer *byte
341+
status, _, _ := procNetUserGetInfo.Call(
342+
0, // local machine
343+
uintptr(unsafe.Pointer(name)),
344+
1, // level: USER_INFO_1
345+
uintptr(unsafe.Pointer(&buffer)),
346+
)
347+
runtime.KeepAlive(name)
348+
if status == nerrUserNotFound {
349+
return false, nil
350+
}
351+
if err := netAPIStatus("NetUserGetInfo", status); err != nil {
352+
return false, err
353+
}
354+
if buffer == nil {
355+
return false, nil
356+
}
357+
defer procNetApiBufferFree.Call(uintptr(unsafe.Pointer(buffer)))
358+
info := (*userInfo1)(unsafe.Pointer(buffer))
359+
if info.Comment == nil {
360+
return false, nil
361+
}
362+
return windows.UTF16PtrToString(info.Comment) == windowsSandboxUserComment, nil
363+
}
364+
320365
// resolveWindowsSandboxSID looks up the SID for a provisioned principal. The SID
321366
// is the durable handle: account names can collide with a pre-existing local
322367
// user, so every ACE and firewall rule is keyed to the SID rather than the name.
@@ -341,36 +386,49 @@ func resolveWindowsSandboxSID(username string) (*windows.SID, error) {
341386
// live. The returned value is always the account's actual password, including
342387
// when the account already existed, because that case is reset explicitly
343388
// below.
344-
func provisionWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentity, string, error) {
389+
func provisionWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentity, string, bool, error) {
345390
if err := ensureWindowsSandboxGroup(); err != nil {
346-
return windowsSandboxIdentity{}, "", err
391+
return windowsSandboxIdentity{}, "", false, err
347392
}
348393
username := windowsSandboxUserName(workspaceKey)
349394
password, err := newWindowsSandboxPassword()
350395
if err != nil {
351-
return windowsSandboxIdentity{}, "", err
396+
return windowsSandboxIdentity{}, "", false, err
352397
}
353398
existed, err := ensureWindowsSandboxUser(username, password)
354399
if err != nil {
355-
return windowsSandboxIdentity{}, "", err
400+
return windowsSandboxIdentity{}, "", false, err
356401
}
357402
if existed {
358-
// NetUserAdd left the account untouched, so the password above is not yet
403+
// Prove the account is ours before touching it. The name is derived from a
404+
// workspace hash rather than discovered, so it can be occupied by an
405+
// account that has nothing to do with Zero, whether by coincidence or
406+
// because somebody created it deliberately. Adopting one means resetting
407+
// its password, which is not something to do on the strength of a name
408+
// matching a pattern we generate ourselves.
409+
managed, err := windowsSandboxUserIsManaged(username)
410+
if err != nil {
411+
return windowsSandboxIdentity{}, "", false, err
412+
}
413+
if !managed {
414+
return windowsSandboxIdentity{}, "", false, fmt.Errorf("%w: %q", errWindowsSandboxNameCollision, username)
415+
}
416+
// Ours, and NetUserAdd left it untouched, so the password above is not yet
359417
// its password. Set it, or the secret stored by the caller would never
360418
// authenticate and every command would fail to log on with a principal
361419
// that looks perfectly provisioned.
362420
if err := resetWindowsSandboxUserPassword(username, password); err != nil {
363-
return windowsSandboxIdentity{}, "", err
421+
return windowsSandboxIdentity{}, "", false, err
364422
}
365423
}
366424
if err := addWindowsSandboxUserToGroup(username); err != nil {
367-
return windowsSandboxIdentity{}, "", err
425+
return windowsSandboxIdentity{}, "", !existed, err
368426
}
369427
sid, err := resolveWindowsSandboxSID(username)
370428
if err != nil {
371-
return windowsSandboxIdentity{}, "", err
429+
return windowsSandboxIdentity{}, "", !existed, err
372430
}
373-
return windowsSandboxIdentity{Username: username, SID: sid}, password, nil
431+
return windowsSandboxIdentity{Username: username, SID: sid}, password, !existed, nil
374432
}
375433

376434
// removeWindowsSandboxIdentity deletes a provisioned principal. Callers must

‎internal/sandbox/windows_identity_windows_test.go‎

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package sandbox
44

55
import (
66
"errors"
7+
"fmt"
78
"os"
89
"strings"
910
"testing"
@@ -213,7 +214,7 @@ func TestProvisionWindowsSandboxIdentityRoundTrip(t *testing.T) {
213214
// from being explained by residue from a previous one.
214215
_ = removeWindowsSandboxIdentity(windowsSandboxUserName("ziptest01"))
215216

216-
identity, password, err := provisionWindowsSandboxIdentity("ziptest01")
217+
identity, password, _, err := provisionWindowsSandboxIdentity("ziptest01")
217218
if err != nil {
218219
t.Fatalf("provision: %v", err)
219220
}
@@ -238,7 +239,7 @@ func TestProvisionWindowsSandboxIdentityRoundTrip(t *testing.T) {
238239
}
239240
// Re-running must converge on the same principal rather than failing or
240241
// creating a second account.
241-
again, secondPassword, err := provisionWindowsSandboxIdentity("ziptest01")
242+
again, secondPassword, _, err := provisionWindowsSandboxIdentity("ziptest01")
242243
if err != nil {
243244
t.Fatalf("second provision: %v", err)
244245
}
@@ -296,7 +297,7 @@ func TestGrantLogonRightsAndMintPrincipalToken(t *testing.T) {
296297
// which the freshly generated one will not match, so start from a clean slate.
297298
_ = removeWindowsSandboxIdentity(windowsSandboxUserName(key))
298299

299-
identity, password, err := provisionWindowsSandboxIdentity(key)
300+
identity, password, _, err := provisionWindowsSandboxIdentity(key)
300301
if err != nil {
301302
t.Fatalf("provision: %v", err)
302303
}
@@ -388,3 +389,50 @@ func TestLookupWindowsSandboxIdentityRejectsNonUserAccount(t *testing.T) {
388389
}
389390
t.Skip("no well-known non-user account resolved on this machine")
390391
}
392+
393+
// The account name is derived from a workspace hash, not discovered, so it can
394+
// be occupied by a local account that has nothing to do with Zero. Adopting one
395+
// means resetting a stranger's password, so provisioning has to prove ownership
396+
// first and refuse otherwise.
397+
//
398+
// Driven against real accounts every Windows install carries, which are
399+
// definitively not ours. Unprivileged: it only has to establish that they are
400+
// not classified as managed, so nothing is ever created or modified.
401+
func TestWindowsSandboxUserIsManagedRefusesForeignAccounts(t *testing.T) {
402+
checked := 0
403+
for _, name := range []string{"Administrator", "Guest", "DefaultAccount"} {
404+
managed, err := windowsSandboxUserIsManaged(name)
405+
if err != nil {
406+
// Localized or disabled installs may not carry every one of these.
407+
continue
408+
}
409+
checked++
410+
if managed {
411+
t.Fatalf("%q classified as a Zero sandbox principal; provisioning would reset its password", name)
412+
}
413+
}
414+
if checked == 0 {
415+
t.Skip("no well-known local account could be queried on this machine")
416+
}
417+
// An absent account must answer false rather than error, since provisioning
418+
// asks this question about names that usually do not exist yet.
419+
managed, err := windowsSandboxUserIsManaged("zero-sbx-nosuchacct")
420+
if err != nil {
421+
t.Fatalf("querying a missing account: %v", err)
422+
}
423+
if managed {
424+
t.Fatal("a missing account was classified as managed")
425+
}
426+
}
427+
428+
// The refusal has to be a typed, recognisable collision rather than a generic
429+
// failure, so setup can say what is wrong instead of reporting a Win32 status.
430+
func TestWindowsSandboxNameCollisionIsTyped(t *testing.T) {
431+
wrapped := fmt.Errorf("%w: %q", errWindowsSandboxNameCollision, "zero-sbx-dexample")
432+
if !errors.Is(wrapped, errWindowsSandboxNameCollision) {
433+
t.Fatal("collision error does not match its sentinel")
434+
}
435+
if !strings.Contains(wrapped.Error(), "not created by Zero") {
436+
t.Fatalf("collision message = %q, want it to say the account is not ours", wrapped.Error())
437+
}
438+
}

0 commit comments

Comments
 (0)