Skip to content

Commit 107d5af

Browse files
committed
Add support for the undocumented @token. (0xfc) attribute token (Part 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.
1 parent 788126e commit 107d5af

5 files changed

Lines changed: 144 additions & 2 deletions

File tree

ace/condition/condition.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ const (
4646
tokenUserAttr byte = 0xf9
4747
tokenResourceAttr byte = 0xfa
4848
tokenDeviceAttr byte = 0xfb
49+
// tokenTokenAttr is a fifth attribute type that Windows implements but
50+
// MS-DTYP 2.4.4.17.8 does not document, carrying the SDDL prefix "@TOKEN.".
51+
// Its encoding is identical to the other attribute tokens. The kernel
52+
// evaluator treats it as its own attribute source, read from the access
53+
// token rather than from the user-claims collection that 0xf9 uses, so it is
54+
// not a synonym for tokenUserAttr.
55+
tokenTokenAttr byte = 0xfc
4956

5057
// Binary relational operators.
5158
tokenEqual byte = 0x80
@@ -194,7 +201,7 @@ type Node interface {
194201

195202
// Attribute is an attribute-name operand (local or @User./@Resource./@Device.).
196203
type Attribute struct {
197-
Token byte // one of tokenLocalAttr/tokenUserAttr/tokenResourceAttr/tokenDeviceAttr
204+
Token byte // one of tokenLocalAttr/tokenUserAttr/tokenResourceAttr/tokenDeviceAttr/tokenTokenAttr
198205
Name string
199206
}
200207

ace/condition/condition_test.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,134 @@ func TestUnaryKeywordOperatorsRejectedInInfixPosition(t *testing.T) {
218218
})
219219
}
220220
}
221+
222+
// TestTokenAttribute_0xfc covers the fifth attribute token, 0xfc, whose SDDL
223+
// prefix is "@TOKEN.". MS-DTYP 2.4.4.17.8 documents only 0xf8-0xfb and its
224+
// 2.5.1.1 ABNF admits only @user./@device./@resource., but Windows implements
225+
// 0xfc in both directions: sechost.dll and advapi32.dll parse "@TOKEN." into
226+
// 0xfc and render 0xfc back to "@TOKEN.", and the kernel evaluator gives it its
227+
// own attribute source class.
228+
//
229+
// Its wire encoding is identical to the other attribute tokens: the token byte,
230+
// a DWORD byte length, then the UTF-16 name.
231+
func TestTokenAttribute_0xfc(t *testing.T) {
232+
// The token byte lands where the other attribute tokens do: straight after
233+
// the 4-byte "artx" signature for a leading attribute operand.
234+
raw, err := condition.Marshal(`(@TOKEN.foo == 1)`)
235+
if err != nil {
236+
t.Fatalf("Marshal() error = %v", err)
237+
}
238+
if len(raw) < 5 {
239+
t.Fatalf("encoded condition too short: %s", hex.EncodeToString(raw))
240+
}
241+
if raw[4] != 0xfc {
242+
t.Fatalf("attribute token = 0x%02x, want 0xfc (encoded: %s)", raw[4], hex.EncodeToString(raw))
243+
}
244+
245+
// Serialization uses this package's existing capitalisation convention,
246+
// matching @User./@Device./@Resource. rather than Windows' all-caps form.
247+
got, err := condition.Unmarshal(raw)
248+
if err != nil {
249+
t.Fatalf("Unmarshal() error = %v", err)
250+
}
251+
if got != `@Token.foo == 1` {
252+
t.Fatalf("Unmarshal() = %q, want %q", got, `@Token.foo == 1`)
253+
}
254+
}
255+
256+
// TestTokenAttribute_PrefixIsCaseInsensitive checks that the @TOKEN. prefix folds
257+
// case like the three documented prefixes, and that every casing yields token 0xfc.
258+
func TestTokenAttribute_PrefixIsCaseInsensitive(t *testing.T) {
259+
for _, expr := range []string{
260+
`(@TOKEN.foo == 1)`, `(@token.foo == 1)`, `(@Token.foo == 1)`, `(@ToKeN.foo == 1)`,
261+
} {
262+
t.Run(expr, func(t *testing.T) {
263+
raw, err := condition.Marshal(expr)
264+
if err != nil {
265+
t.Fatalf("Marshal(%q) error = %v", expr, err)
266+
}
267+
if raw[4] != 0xfc {
268+
t.Fatalf("attribute token = 0x%02x, want 0xfc", raw[4])
269+
}
270+
})
271+
}
272+
}
273+
274+
// TestTokenAttribute_DecodeWindowsBlob decodes a hand-assembled payload of the
275+
// shape Windows produces — 0xfc, DWORD length, UTF-16 name — proving the decoder
276+
// no longer rejects the token. Before this was supported, Unmarshal failed with
277+
// "unknown conditional-expression token 0xfc".
278+
func TestTokenAttribute_DecodeWindowsBlob(t *testing.T) {
279+
// artx | 0xfc len=6 "foo" | 0x04 int64(1) sign=none base=dec | 0x80 == | pad
280+
blob, err := hex.DecodeString(
281+
"61727478" + "fc06000000" + "66006f006f00" +
282+
"04" + "0100000000000000" + "0302" + "80" + "00")
283+
if err != nil {
284+
t.Fatalf("bad test vector: %v", err)
285+
}
286+
got, err := condition.Unmarshal(blob)
287+
if err != nil {
288+
t.Fatalf("Unmarshal() error = %v", err)
289+
}
290+
if got != `@Token.foo == 1` {
291+
t.Fatalf("Unmarshal() = %q, want %q", got, `@Token.foo == 1`)
292+
}
293+
}
294+
295+
// TestTokenAttribute_RoundTripStable checks Marshal -> Unmarshal -> Marshal is
296+
// byte-stable for 0xfc across operand positions and operator kinds, and that it
297+
// composes with the documented attribute tokens.
298+
func TestTokenAttribute_RoundTripStable(t *testing.T) {
299+
for _, expr := range []string{
300+
`(@Token.foo == 1)`,
301+
`(@Token.dept == "eng")`,
302+
`(@Token.a == @Token.b)`,
303+
`(@Token.a Any_of {1, 2})`,
304+
`(@Token.a && @User.b)`,
305+
`(@Token.flags == 0x10)`,
306+
`(Exists @Token.foo)`,
307+
} {
308+
t.Run(expr, func(t *testing.T) {
309+
first, err := condition.Marshal(expr)
310+
if err != nil {
311+
t.Fatalf("Marshal(%q) error = %v", expr, err)
312+
}
313+
text, err := condition.Unmarshal(first)
314+
if err != nil {
315+
t.Fatalf("Unmarshal() error = %v", err)
316+
}
317+
second, err := condition.Marshal("(" + text + ")")
318+
if err != nil {
319+
t.Fatalf("re-Marshal(%q) error = %v", text, err)
320+
}
321+
if hex.EncodeToString(first) != hex.EncodeToString(second) {
322+
t.Fatalf("round-trip not byte-stable:\n first = %s\n second = %s",
323+
hex.EncodeToString(first), hex.EncodeToString(second))
324+
}
325+
})
326+
}
327+
}
328+
329+
// TestTokenAttribute_NotAliasOfUserAttr guards against implementing @TOKEN. as a
330+
// synonym for @USER.. They are separate tokens with separate value sources: the
331+
// kernel evaluator reads 0xfc from the access token and 0xf9 from the user-claims
332+
// collection, and assigns them different internal source classes.
333+
func TestTokenAttribute_NotAliasOfUserAttr(t *testing.T) {
334+
tokenRaw, err := condition.Marshal(`(@Token.foo == 1)`)
335+
if err != nil {
336+
t.Fatalf("Marshal(@Token.) error = %v", err)
337+
}
338+
userRaw, err := condition.Marshal(`(@User.foo == 1)`)
339+
if err != nil {
340+
t.Fatalf("Marshal(@User.) error = %v", err)
341+
}
342+
if tokenRaw[4] == userRaw[4] {
343+
t.Fatalf("@Token. and @User. encoded to the same token 0x%02x; they must differ", tokenRaw[4])
344+
}
345+
if userRaw[4] != 0xf9 {
346+
t.Fatalf("@User. token = 0x%02x, want 0xf9", userRaw[4])
347+
}
348+
if text, _ := condition.Unmarshal(userRaw); text != `@User.foo == 1` {
349+
t.Fatalf("@User. round-trip = %q, want %q", text, `@User.foo == 1`)
350+
}
351+
}

ace/condition/decode.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ func decodeToken(data []byte) (Node, int, error) {
130130
}
131131
return &Composite{Items: items}, 5 + l, nil
132132

133-
case tokenLocalAttr, tokenUserAttr, tokenResourceAttr, tokenDeviceAttr:
133+
case tokenLocalAttr, tokenUserAttr, tokenResourceAttr, tokenDeviceAttr, tokenTokenAttr:
134134
s, n, err := decodeUTF16LenPrefixed(data[1:])
135135
if err != nil {
136136
return nil, 0, err

ace/condition/parser.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,8 @@ func (p *parser) parseAttribute() (Node, error) {
425425
return &Attribute{Token: tokenDeviceAttr, Name: name[len("@device."):]}, nil
426426
case strings.HasPrefix(lower, "@resource."):
427427
return &Attribute{Token: tokenResourceAttr, Name: name[len("@resource."):]}, nil
428+
case strings.HasPrefix(lower, "@token."):
429+
return &Attribute{Token: tokenTokenAttr, Name: name[len("@token."):]}, nil
428430
case strings.HasPrefix(name, "@"):
429431
return nil, fmt.Errorf("unknown attribute prefix in %q", name)
430432
default:

ace/condition/serialize.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ func attributeText(a *Attribute) string {
111111
return "@Device." + a.Name
112112
case tokenResourceAttr:
113113
return "@Resource." + a.Name
114+
case tokenTokenAttr:
115+
return "@Token." + a.Name
114116
default:
115117
return a.Name
116118
}

0 commit comments

Comments
 (0)