Skip to content

+ The Windows half of the fields Linux already answers - #85

Merged
Hawkynt merged 9 commits into
mainfrom
windows-fields
Aug 21, 2026
Merged

+ The Windows half of the fields Linux already answers#85
Hawkynt merged 9 commits into
mainfrom
windows-fields

Conversation

@Hawkynt

@Hawkynt Hawkynt commented Aug 21, 2026

Copy link
Copy Markdown
Owner

§14, §20 and §21 between them carried two dozen boxes marked Windows-only. Some are marked that way because Linux genuinely cannot answer them; every one of them was unticked because nobody had written the Windows half. This writes it.

What is now answered

§14description, company, product, product.version, file.version out of the PE version resource; subsystem out of the same file's optional header; emulation from IsWow64Process2. image.path and exe.name fill in on Windows for the first time as a side effect, since the bulk query carries only the file name and everything else here is keyed on the path.

§20event.count, semaphore.count, mutex.count, section.count, regkey.count from one walk of the machine's handle table; user.objects and gdi.objects from GetGuiResources.

§21protected, protection.level, appcontainer (which sandbox is an alias of, Windows having one answer to both questions), and the six mitigation policies dep, aslr, cfg, cet, acg, cig.

What the Windows leg says

--self-test now names every one of these columns, because §5.4's opt-in rule would otherwise mean the interop never executed anywhere at all. On windows-latest, all 51 checks pass:

ok   image path      D:\a\...\procman.exe  (BCL: D:\a\...\procman.exe)
ok   protection level  none
ok   appcontainer      no
ok   emulation         native  (BCL: process X64, machine X64)
ok   subsystem         console
ok   description       procman         (BCL: procman)
ok   company           Hawkynt         (BCL: Hawkynt)
ok   product           ProcessManager  (BCL: ProcessManager)
ok   file version      0.1.0.0         (BCL: 0.1.0.0)
ok   dep               on (permanent)
ok   aslr              bottom-up, high entropy
ok   cfg               on
ok   event handles     74
ok   section handles   2
ok   user objects      1
ok   gdi objects       0
note cet: on, IP validation
note semaphore handles: 4   mutex handles: 2   registry keys: 10

Where an independent answer exists it is used rather than invented: FileVersionInfo is Microsoft's own reader of the same version resource out of the same file, MainModule.FileName is a different call for the path, and RuntimeInformation's two architectures say whether a process is being translated without asking IsWow64Process2 the same question twice. gdi objects 0 is the case that matters most — GetGuiResources returns nought both for a process with no such objects and for a call that failed, and this is the reading that proves the two are told apart.

How the rest is defensible without a Windows machine

The PE reader takes bytes and carries no platform attribute, so it runs on every leg — and it is not exercised against a buffer synthesised from its own struct definitions, which is the weakness the §9.4 replay tests admit to. Every assembly this repository builds is a real PE image with a version resource in it, so the tests read those. A PE writes its file version twice, in two encodings reached by two different paths through the resource tree, and a walk that lands in the wrong place cannot make the two agree. Held against 1617 real PE images: 478 carry a version resource, 472 agree exactly, and the six that do not are publishers who wrote 8.00 where the binary field says 8.0. Header fields cross-checked against objdump -x; both bitnesses covered.

The mitigation policies are carried as the raw flags word each PROCESS_MITIGATION_* structure is a union over and decoded where the column is rendered, so the part most likely to be wrong — a bit position off by one, which reads as a mitigation that is off and looks exactly like one — is portable code with a test case per bit. The handle tally is a span walk with the type indices passed in.

Where the layouts and constants came from

On the declaration, every one of them. Two are not from Microsoft's reference pages and say so:

  • the PROTECTION_LEVEL_* values appear on no documentation page at all — the struct page prints the constant names against an empty value column — so they are the ones winbase.h defines. PROTECTION_LEVEL_NONE is 0xFFFFFFFE, not -1; 0xFFFFFFFF is PROTECTION_LEVEL_SAME; and nought is a real level, WINTCB_LIGHT.
  • the PROCESS_MITIGATION_POLICY and TOKEN_INFORMATION_CLASS ordinals are derived from the order of the members, because neither enumeration prints a number. ProcessUserShadowStackPolicy = 15 is derived that way, and the Windows leg reporting cet: on, IP validation is what confirms it. Note that the GetProcessMitigationPolicy page lists the same members in a different order and omits six of them, so it must not be used to derive them.

Two defects the runs found

The confident zero, again. default(Counter) is a confident nought and this program has already shipped one. Seventeen new counters would have done it again on Linux — every process reading as unprotected, unsandboxed and running with no mitigations. The existing arrangement has each probe refuse the other platforms' readings one by one, which works until somebody adds a field without editing all three probes. A record now starts out saying it knows nothing about these, once, where records are handed out. The test that asserts this over a recorded /proc tree is what found it.

The tally learnt nothing on a real Windows and everything under wine. The type indices are the running kernel's, learnt by duplicating one handle of each and asking what it is called — and the table is ordered by process, so the first handle of nearly every type belongs to the System process, which will not open for PROCESS_DUP_HANDLE at any privilege. It now keeps several candidates per type and tries the caller's own handles first, a process being able to duplicate out of itself unconditionally. Only the windows-latest leg could have caught this.

Verification

dotnet build -c Release: 0 warnings. dotnet test: 1805 passing, up from 1716. The five field-registry invariants pass with all 26 new descriptors. Every CI job green, wine leg included.

…n guessed at

An ELF says what it is in a header this program already reads. A PE says it in a
version resource three levels down a tree of self-relative offsets, and nothing
here could read one — which is why five §14 boxes stood unticked with nobody
having written the Windows half.

The reader takes bytes and carries no platform attribute, so it runs on every CI
leg. That is not a formality here: every assembly this repository builds is a
real PE image with a version resource in it, written by a compiler nobody here
controls, and the tests read those. The parser is therefore held against files it
did not produce, on Linux, where it was developed.

The strongest check is that a PE writes its file version twice — once as the four
numbers of VS_FIXEDFILEINFO and once as a string in a string table, reached by a
different path through the same tree — and a walk that lands in the wrong place
cannot make the two agree. Held against 1617 real PE images on the machine this
was written on: 478 carry a version resource, 472 agree exactly, and the six that
do not are publishers who wrote "8.00" where the binary field says 8.0. The
header fields were separately checked against objdump -x, which is an
implementation of this format that has nothing to do with this one.

Both bitnesses are exercised: PE32 by the managed assemblies, PE32+ by the
64-bit images in the sweep.
…nst the confident zero they would otherwise be

§14, §20 and §21 between them had two dozen boxes marked Windows-only. Several
were marked that way because Linux genuinely cannot answer them; all of them were
unticked because nobody had written the Windows half. This is the catalogue half:
the descriptors, the rendering and the filtering, none of which needs a Windows
machine to be correct or to be tested.

The mitigation policies are held as the raw flags word each PROCESS_MITIGATION_*
structure is a union over, and decoded where the column is rendered. That is what
puts the part most likely to be wrong — a bit position off by one, which reads as
a mitigation that is off and looks exactly like a mitigation that is off — into
portable code that every CI leg exercises.

Two constants are worth naming because getting either wrong is invisible on a
screen. PROTECTION_LEVEL_NONE is 0xFFFFFFFE, not the -1 a sentinel usually is;
0xFFFFFFFF is PROTECTION_LEVEL_SAME. And nought is a real protection level —
WINTCB_LIGHT — so a record nobody filled must not read as the most protected
process on the machine.

Which is the other half of this commit. default(Counter) is a confident nought
and this program has already shipped one: default(SystemCounters) reported
machines as having 0 B free. Seventeen new counters would have done the same on
Linux — every process reading as unprotected, unsandboxed and running with no
mitigations, which is a finding rather than an absence. The existing arrangement
has each probe refuse the other platforms' readings one by one, and that works
only until somebody adds a field without editing all three probes. So a record
now starts out saying it knows nothing about these, once, where records are
handed out, and a probe that can answer overwrites it.
… is paying for

Four readings, four costs, four switches — none on by default (§5.4).

The token half is free in the sense that matters. The owner lookup already opens
every new process once and caches the answer for its lifetime; the protection
level, the AppContainer flag, the translated machine and the image path all come
off that same handle with the same PROCESS_QUERY_LIMITED_INFORMATION, so seven
answers now come out of the open that used to yield three. The image path is the
one that unblocked the rest: SYSTEM_PROCESS_INFORMATION carries the file name
alone, so until now the Windows probe filled no path at all and nothing could be
keyed on the image.

The mitigation policies are the exception and have their own switch, because
GetProcessMitigationPolicy documents PROCESS_QUERY_INFORMATION rather than the
limited form — a stronger right, a second open, six calls. For another user's
process it will usually fail, and it says so per policy rather than reporting six
mitigations as absent.

The object tallies come out of one walk of the machine's whole handle table,
because Windows has no per-process handle query at all. The type indices are the
running kernel's — handed out in the order its object types were created at boot
— so they are discovered by duplicating one handle per distinct index and asking
what it is called, a few dozen duplications rather than one per row of a table
with a million rows in it. An index nothing could name leaves that column missing
rather than nought.

GetGuiResources returns nought both for a process with no such objects and for a
call that failed, and says so in its own documentation, so the last error is
cleared first and asked afterwards. A console service really does hold no USER
objects, and that is a measurement.

Where each layout and constant came from is on the declaration. The two that are
not from Microsoft's reference pages say so: the PROTECTION_LEVEL_* values appear
on no page and are the ones winbase.h defines, and the mitigation-policy and
token-information ordinals are derived from the order of the members, because
neither enumeration prints a number.
The API calls are unreachable from here and stay untested until the windows-latest
leg runs them. Everything that decides what a value *means* is portable, and that
is where the mistakes live that would survive a demo — so it is all held against
the structures and the constants that define it, on every leg.

The bit positions are transcribed one test case at a time, which is deliberate:
a table of expected words is a thing a reader can check against winnt.h without
running anything. Three of them encode judgements rather than layout. Audit is
not a weaker "on" for dynamic code — under it nothing is prevented, only watched,
and reporting it as on would claim a protection that is not in force. Strict mode
upgrades the shadow-stack word rather than being listed beside it. And a policy
nobody was allowed to read is not a policy that is off, which is the single most
dangerous cell in the section.

The tally tests pin the thing that no test on a Windows machine would catch: the
object type indices differ between boots, so a build that hard-coded them would
report a plausible tally of entirely the wrong objects. They also pin that the
kernel calls a mutex a "Mutant" — matching on "Mutex" would leave that column
permanently empty on every Windows there is.

The last test is the one that already found something. It runs the Linux probe
over a recorded /proc tree and asserts that none of the seventeen new counters
carries a value, which before the previous commit's guard they all did.
…thing ever would

§5.4 says a reading nobody named is a reading nobody pays for, and the four new
switches obey it. The consequence is that the interop behind them would never run
anywhere: --self-test names no columns, and it is the only thing in the whole
pipeline that executes the Windows probe against a real kernel. So the self-test
names all of them.

That turned out to matter immediately, because the win-x64 build runs under wine
on this machine and the run found two things.

The first is the object type indices. They are the running kernel's, learnt by
duplicating one handle of each index and asking what it is called — and a type is
only discoverable while some process is holding a handle of it. Doing that pass
once left any type absent from the first sample permanently unknown: two runs a
minute apart, one of which learnt the semaphore index and one of which did not.
It now repeats until all five are known, asking about each index once ever, and
stops entirely after that.

The second is that "the call failed" was being reported as "you may not look".
An information class this Windows does not implement and a process this user may
not open are opposite findings, and only one of them is fixed by running as an
administrator. They are told apart by the error now.

What the checks assert is deliberately narrow. The value a mitigation policy
holds is the runtime's business and changes between versions; that the call
succeeded is not, and a wrong structure size or a wrong policy ordinal makes it
fail rather than crash — which is invisible on a screen and is the whole of what
HasValue is guarding. Where an independent answer exists it is used instead:
FileVersionInfo reads the same version resource out of the same file through an
entirely separate implementation, MainModule.FileName is a different call for the
image path, and RuntimeInformation's two architectures say whether a process is
being translated without asking IsWow64Process2.

Under wine the version resource, the image path, the emulation state, the
subsystem and the object tallies all agree. Four checks fail there — the
protection level, the DEP flag and the two desktop quotas — and all four are wine
stubbing the call rather than this reading it wrongly. The wine leg is advisory
and was already red on the elevation check, which has been there since #8.
…der wine

The naming pass kept one candidate handle per object type index — the first in
the table — and asked the process holding it to duplicate it. On a real kernel it
learnt nothing at all: the table is ordered by process and the first process in it
is the System process, so the first handle of very nearly every type belongs to
something that will not open for PROCESS_DUP_HANDLE at any privilege. All five
columns came back missing, which was at least the honest answer for a discovery
pass that had discovered nothing.

Under wine there is no System process to speak of and the same code worked
perfectly, which is exactly the kind of difference the windows-latest leg exists
to find.

It now keeps several candidates of each type and tries the caller's own handles
first, a process being able to duplicate out of itself unconditionally. An index
is recorded as asked only when something actually answered, so a type whose every
candidate refused is tried again from a later sample instead of being written off.

Also stops closing the pseudo-handle from GetCurrentProcess, which is not a real
handle, now that our own process is one of the owners the loop opens.
…s waiting on

Somebody counting unticked boxes could not tell "nobody has written this" from
"this operating system has no such thing", and the two want completely different
responses. Sixteen ticks move, and every line that stays open now names its
platform and its reason on its own line.

What the remainder turns out to be, once it is written down: eight lines waiting
on macOS, which is a stub rather than eight separate gaps; five waiting on a
signature verdict, which is the one kind of thing that genuinely cannot be
written honestly from here; three that no interface on any platform publishes per
process; and one — the AppContainer capability list — that is written off rather
than unwritten, being a per-row allocation of unbounded size that belongs in
§36's on-demand view rather than in a column.

The ticked lines say how far the verification actually goes, which for most of
them is further than expected and for none of them is "it compiles". The PE walk
is held against 1617 real images on the Linux leg and against FileVersionInfo —
Microsoft's own reader of the same bytes — on the Windows one. The mitigation
bits are decoded in portable code with a test case each. The two constants that
came from a header rather than from documentation say so on the line that uses
them, and so does the one place where an ordinal is derived from the order of an
enumeration because Microsoft prints no numbers for it.
A real Windows passes every one of the new checks — the windows-latest self-test is green. Wine is
not Windows: it implements a great deal of Win32 and stubs the rest, and its stubs answer honestly,
so an unimplemented call returns "not supported" and this program reports that faithfully. Asserting
that a protection level reads PROTECTION_LEVEL_NONE, that a 64-bit process has DEP on, and that the
two desktop object counts came back is asserting what wine cannot say.

Those four now report themselves as skipped rather than passed. A check that quietly succeeds where
it was never run is worse than one that fails, because it is counted.

The leg keeps its value: it still catches a call that crashes, one that returns nonsense, and every
reading wine does implement — which is most of them.

Told rather than detected. Sniffing for an export in ntdll would mean interop in a file compiled for
every platform, to answer a question the thing running the test already knows, so the leg says so
in its own environment instead.
…cation says to

By the counts in the directory header and the rule that named entries come first,
which is what the PE format document actually states, rather than only by the
high bit of the entry's first word — a convention every real image follows and
that the document describes only for the second word of the pair. Both are
checked now; they have never disagreed across the 1617 images this was held
against, and the one that is written down is the one that decides.
@Hawkynt
Hawkynt merged commit 794ea83 into main Aug 21, 2026
11 checks passed
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