Skip to content

Add support for the undocumented @TOKEN. (0xfc) attribute token (Part of #135) - #138

Merged
p0dalirius merged 1 commit into
mainfrom
enhancement-token-attribute-0xfc
Jul 29, 2026
Merged

Add support for the undocumented @TOKEN. (0xfc) attribute token (Part of #135)#138
p0dalirius merged 1 commit into
mainfrom
enhancement-token-attribute-0xfc

Conversation

@p0dalirius

Copy link
Copy Markdown
Collaborator

Linked Issue

Part of #135

Deliberately not a closing keyword. #135 covers two undocumented tokens — 0xfc (@TOKEN.) and 0xa3 (&). This PR implements only 0xfc. 0xa3 is left open there: its semantics are now known (a bitwise AND flag test on two 64-bit integers, resolved in the issue's comments) but choosing how to represent it needs a call on precedence that is better made separately — see Notes.

Root Cause

Windows implements a fifth conditional-expression attribute token, 0xfc, carrying the SDDL prefix @TOKEN.. MS-DTYP 2.4.4.17.8 documents only 0xf80xfb, and its 2.5.1.1 ABNF admits only @user. / @device. / @resource.. This package was written to the specification, so it rejected the token in both directions: Unmarshal failed with unknown conditional-expression token 0xfc, and the parser with unknown attribute prefix in "@TOKEN.foo".

The token is not an obscure corner. It is implemented in both directions by sechost.dll and advapi32.dll, which parse @TOKEN. into 0xfc and render 0xfc back to @TOKEN., and it is evaluated by the kernel: ntoskrnl.exe's conditional-expression evaluator dispatches on the attribute token and gives 0xfc its own internal source class (6), reading its value from the access token rather than from the user-claims collection that 0xf9 uses. Evidence for all of that is in #135.

Fix Description

Four small additions, since the wire encoding of 0xfc is identical to the other attribute tokens — token byte, DWORD byte length, UTF-16 name:

File Change
ace/condition/condition.go tokenTokenAttr byte = 0xfc, with a comment recording that it is undocumented and not a synonym for tokenUserAttr; Attribute.Token doc updated
ace/condition/parser.go @token. arm in parseAttribute()'s prefix ladder, placed with the other three so it inherits the existing case folding
ace/condition/serialize.go tokenTokenAttr"@Token." in attributeText()
ace/condition/decode.go tokenTokenAttr added to the attribute case in decodeToken()

encode.go needed no change: it writes Attribute.Token generically.

Two deliberate choices:

  • Kept distinct from tokenUserAttr. Mapping @TOKEN. onto 0xf9 would have been a one-line shortcut and is wrong — the two differ at every layer in Windows (parse ladder, render string, the LocalGetReferencedTokenTypesForCondition bitmask, and the evaluator's source class). A test pins this so a future simplification cannot collapse them.
  • Serializes as @Token., matching this package's existing capitalisation of @User. / @Device. / @Resource.. Windows renders all four in all-caps (@TOKEN., @USER., …) and this package already normalises the documented three, so following the local convention keeps output internally consistent. Parsing folds case, so @TOKEN., @token. and @ToKeN. are all accepted.

How Verified

Runtime, before the fix:

(@TOKEN.foo == 1) from text     REJECT  unknown attribute prefix in "@TOKEN.foo"
0xfc binary blob                REJECT  unknown conditional-expression token 0xfc
0xf9 same blob shape (control)  accept  -> "@User.foo == 1"

Runtime, after the fix — text → binary → text, with the token byte inspected:

(@TOKEN.foo == 1)              -> @Token.foo == 1        tok=0xfc
(@token.foo == 1)  lowercase   -> @Token.foo == 1        tok=0xfc
(@ToKeN.foo == 1)  mixed       -> @Token.foo == 1        tok=0xfc
(@USER.foo == 1)   control     -> @User.foo == 1         tok=0xf9
(@RESOURCE.foo==1) control     -> @Resource.foo == 1     tok=0xfa

Decoding a hand-assembled payload of the shape Windows emits:

blob 61727478 fc06000000 66006f006f00 04 0100000000000000 0302 80 00
  -> "@Token.foo == 1"

Full security-descriptor round-trip, so the token works inside a real conditional ACE and not just in the condition codec:

in : D:P(XA;;WP;;;WD;(@Token.a && @User.b))
out: D:P(XA;;WP;;;WD;(@Token.a && @User.b))     identical
in : D:P(XD;;WP;;;WD;(@Token.dept == "eng"))
out: D:P(XD;;WP;;;WD;(@Token.dept == "eng"))    identical

Regression check — with the new tests present but the four source files reverted to their pre-fix state, go test ./ace/condition/... reports 19 failures. With the fix, go test ./... is green across the repository, gofmt -l ace/ is clean, and go vet reports nothing.

Live sanity check against AD DS (Server 2025 lab): a 0xfc condition written to a real object is stored intact by the DC and read back unchanged, so descriptors containing this token do occur in a form this package must be able to read. Details in #135.

Test Coverage

Added — in ace/condition/condition_test.go:

  • TestTokenAttribute_0xfc — asserts the encoded token byte is 0xfc and the text form round-trips to @Token.foo == 1.
  • TestTokenAttribute_PrefixIsCaseInsensitive — all four casings of the prefix yield 0xfc.
  • TestTokenAttribute_DecodeWindowsBlob — decodes a hand-assembled Windows-shaped payload; this is the case that previously failed with unknown conditional-expression token 0xfc.
  • TestTokenAttribute_RoundTripStableMarshal → Unmarshal → Marshal is byte-stable across seven expressions covering both operand positions, string/int/hex literals, composites, && mixing with @User., and Exists.
  • TestTokenAttribute_NotAliasOfUserAttr — asserts @Token. and @User. encode to different tokens and that @User. is still 0xf9.

Scope of Change

  • Files changed: ace/condition/condition.go, ace/condition/parser.go, ace/condition/serialize.go, ace/condition/decode.go, ace/condition/condition_test.go
  • Submodule pointer updated: no
  • Behavioral changes outside the bug fix: none. One consequence internal to the change: an attribute literally named @token.something previously produced unknown attribute prefix and now parses as a token attribute. That is the intent, and @ -prefixed names were already reserved — the existing case strings.HasPrefix(name, "@") arm rejected every unrecognised @ prefix, so no previously-valid expression changes meaning.

Risk and Rollout

Additive. The change widens the set of accepted input and adds one serializer arm reachable only for a token that previously could not be constructed. No existing token's encoding, decoding or text form is altered. Safe to merge without staged rollout.

Notes

Why 0xa3 is not in this PR. Its semantics are settled — bitwise AND of two 64-bit integers, non-zero meaning TRUE, resolved by decompiling ntoskrnl.exe (see #135) — but supporting it means committing to a precedence of 10, which is the lowest in Windows' operator table, below || (11) and && (12). That makes a & b || c parse as a & (b || c). Reusing &&'s precedence would silently reassociate expressions, so the value of encoding it at all, versus decoding it only so such an ACE round-trips instead of erroring, is a judgement worth making on its own rather than bundling here.

Two observations noted while testing, both out of scope and neither addressed:

  • Exists / Not_Exists in Windows reject an operand of type 0xf9, 0xfb or 0xfc while permitting 0xf8 and 0xfa — the rejected three being exactly the sources drawn from the security context. This package accepts Exists on any attribute, including @Token., and this PR does not change that; adding the restriction would be a new rejection of input that currently parses.
  • Integer literals are re-encoded at their narrowest width, so a value of 1 supplied as an int64 (0x04) comes back as an int8 (0x01). Self-consistent round-trips are byte-stable, so this is not a defect on its own, but it may be an interop difference against Windows' encoder and would want its own investigation before anyone relies on byte-identical re-encoding of externally-produced blobs.

… of #135)

Windows implements a fifth conditional-expression attribute token, 0xfc, with the
SDDL prefix "@token.". MS-DTYP 2.4.4.17.8 documents only 0xf8-0xfb and its
2.5.1.1 ABNF admits only @user./@device./@resource., so a codec written to the
specification rejects it: Unmarshal failed with "unknown conditional-expression
token 0xfc" and the parser with "unknown attribute prefix".

The token is implemented in both directions by sechost.dll and advapi32.dll, which
parse "@token." into 0xfc and render 0xfc back to "@token.", and it is evaluated
by the kernel: ntoskrnl.exe's evaluator assigns it its own internal attribute
source class and reads it from the access token, not from the user-claims
collection that 0xf9 uses. It is therefore not a synonym for tokenUserAttr, and is
deliberately kept distinct.

Its wire encoding is identical to the other attribute tokens - token byte, DWORD
byte length, UTF-16 name - so the change is four small additions: the constant,
the prefix arm in parseAttribute, the serializer arm, and the token in the
decoder's attribute case. encode.go needed no change because it writes
Attribute.Token generically.

Serialization emits "@token." to match this package's existing capitalisation of
@user./@device./@resource. rather than Windows' all-caps rendering; parsing folds
case, so either form is accepted on input.
@p0dalirius
p0dalirius force-pushed the enhancement-token-attribute-0xfc branch from c5e9666 to 107d5af Compare July 29, 2026 10:41
@p0dalirius
p0dalirius merged commit e51c8c3 into main Jul 29, 2026
5 checks passed
@p0dalirius
p0dalirius deleted the enhancement-token-attribute-0xfc branch July 29, 2026 10:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant