ADR-012: com_register loads the publisher's DLL in-process¶
- Status: Accepted
- Date: 2026-08-11
- Decision driver: Register row R36 (audit register row, kept in the private development repository),
raised during the v0.1.0-alpha release-candidate audit and assigned to
Stage 2 lane S6. The row observes that
DllRegisterServerexecutes in-process at high integrity, so a malformed or hijacked publisher DLL crashes or takes over the elevated installer rather than a disposable child, and asks for either an ADR recording the trust assumption or a move to a child process. This is that ADR, and it chooses the former.
Decision (TL;DR)¶
com_register keeps loading the publisher's COM DLL into the elevated
installer process and invoking its DllRegisterServer export there
(src/SigilBuild.Wrapper.Core/Steps/Win32/ComRegistration.cs,
Invoke). It does not shell out to regsvr32.exe.
The reason is not that isolation would be expensive. It is that a child
process would not be an isolation boundary at all. A child spawned by the
installer inherits the installer's token, so regsvr32.exe would run at exactly
the same integrity level, with exactly the same ability to write
HKLM\Software\Classes — which is the whole point of the step. Moving the load
out of process converts an availability problem (a buggy DLL kills the
installer) into a slightly smaller availability problem, and converts a
security problem (a hostile DLL runs as administrator) into precisely the same
security problem in a different process. Paying regsvr32's real costs — bitness
selection, unusable exit codes, a LOLBIN invocation in every Sigil-built
installer — for that trade is not worth it.
The trust assumption being accepted, stated plainly:
A
com_registerstep is an explicit grant of arbitrary code execution as administrator to the DLL named bypath. Sigil guarantees only that the DLL is the one the publisher shipped, sitting where only administrators can have put it. It does not, and cannot, guarantee anything about what that DLL'sDllRegisterServerdoes once called.
That assumption is now written where publishers read it
(docs/guides/install-steps.md, com_register), not only where maintainers do.
Context¶
What the step does today¶
ComRegisterStep.RunAsync:
- resolves
paththroughStepContext.ResolvePath(${…}/{…}expansion, unresolved-token refusal,payload://traversal guard); - runs
PrivilegedTargetGuard.Check, which requires the resolved path to sit insideinstall_dir, with no directory junction on the way down, and in a directory that onlyNT AUTHORITY\SYSTEM,BUILTIN\AdministratorsorNT SERVICE\TrustedInstallercan write (register rows R3/R9); - journals the inverse (
RollbackRecord.UnregisterCom) before registering; - calls
ComRegistration.Invoke(path, "DllRegisterServer"), which isLoadLibraryExW(..., LOAD_WITH_ALTERED_SEARCH_PATH)→GetProcAddress→ a call through a C# unmanaged function pointer (delegate* unmanaged[Stdcall]<int>) →FreeLibraryin afinally; - withdraws the step-3 record (
RollbackJournal.RetractLast) when the outcome isLoadFailedorExportMissing— the two cases where the undo's ownDllUnregisterServercannot be called, so replaying it could only report a registration it never managed to probe.OkandHResultFailurekeep the record. Added as the R15 follow-up: R15 made a failedDllUnregisterServermean "the registration is still in place", which turned an unconditionally-journalled record into a guaranteed uninstall failure for a DLL that never registered anything. Note the bar is the undo's feasibility, not proof that nothing was written — a load that fails may still have runDllMain, which is why publishers are told not to register from it.
Step 4 is the one this ADR is about. Steps 1–3 and 5 are unchanged by it and are load-bearing for the analysis below.
Why the row was raised¶
The plain reading of "the installer calls LoadLibrary on a third-party DLL and
invokes an export" is alarming, and correctly so as a description. The
question this ADR has to answer is narrower: would running that same call in a
child process make anything materially safer?
Alternatives considered¶
A. Keep the in-process load (chosen)¶
The DLL is loaded and its export called inside the installer process.
- The publisher's code runs with the installer's token: administrator.
- A DLL that faults takes the installer with it. Because the fault is a process
death rather than a returned failure, the rollback journal does not replay:
the install is left half-applied, and recovery is the user re-running
Setup.exe(whose reinstall path replays the recorded uninstall first) or runningSetup.exe /Uninstall, both of which consume the journal written to disk before the crash. - Nothing new is spawned, so there is no new binary to locate, no new bitness question, and no new process-creation surface.
B. Invoke regsvr32.exe in a child process (rejected)¶
The classic shape: %SystemRoot%\System32\regsvr32.exe /s <dll>, wait, map the
exit code.
Rejected because:
- It is not a privilege boundary.
Process.Startfrom an elevated parent produces an equally elevated child at the same integrity level. The hostile-DLL case — the one that motivates the row — ends with attacker code running as administrator either way. Constructing a genuinely lower-privilege host (CreateProcessAsUserwith a restricted or medium-integrity token) is not available:DllRegisterServerwrites machine-globalHKLM\Software\Classesregistration and would simply fail, which is whycom_registeris machine-scope-only (SIG0310) in the first place. The isolation this alternative promises is unbuildable for this specific operation. - Bitness.
regsvr32.exemust match the DLL's architecture, and the installer cannot tell a 32-bit COM DLL from a 64-bit one without parsing its PE header — new code whose only purpose is to select betweenSystem32andSysWOW64. Choosing wrong produces a confusing failure at install time on the user's machine rather than at pack time on the publisher's. - Exit codes.
regsvr32's exit codes are famously coarse and, with/s, its diagnostics go nowhere. The current path returns the actualHRESULTfromDllRegisterServerand distinguishes "DLL or a dependency would not load" (LoadFailed, with the Win32 error), "not a self-registering COM DLL" (ExportMissing), and "registration failed" (HResultFailure, with the HRESULT) — three genuinely different publisher-facing errors that collapse into one underregsvr32. - It is a LOLBIN.
regsvr32.exeis among the most heavily-flagged living-off-the-land binaries in endpoint detection. Making every Sigil-built installer that registers COM spawn it converts a legitimate operation into an EDR alert on every customer machine. - It adds a target of its own. The child's own image path has to be resolved absolutely and defended, re-introducing a binary-planting question the in-process path does not have.
What it would genuinely buy: crash isolation and a timeout. A hung or
faulting DllRegisterServer would fail the step instead of killing the install.
That is a real benefit, and it is the only one. It is not worth items 1–5, and it
is not the benefit the register row asked for.
C. Keep in-process, add a structured-exception guard (rejected)¶
Wrapping the call so a native access violation becomes a step failure. Rejected
because .NET does not offer a supported, AOT-safe way to do this: corrupted-state
exceptions are not catchable on .NET Core by design, and the vectored-exception-handler
route is exactly the sort of runtime machinery PublishAot / TrimMode=full and
this repo's AOT rules exist to keep out. A guard that catches some faults and
silently misses others would be worse than none, because it would read as
protection.
Decision detail¶
What is relied on instead of isolation¶
The security of com_register rests on provenance of the DLL, not on
containment of its behaviour:
| Control | Where | What it stops |
|---|---|---|
| Path substitution + unresolved-token refusal | StepContext.ResolvePath |
A typo'd or partially-substituted path resolving to something unintended |
install_dir containment, junction-aware |
PrivilegedTargetGuard.Check |
A DLL outside the installed application; a junction planted inside it redirecting the load |
| Admin-only-writable directory requirement | PrivilegedTargetGuard.Check → StateDirectorySecurity |
A non-administrator planting or replacing the DLL — the escalation case |
| Machine-scope-only (SIG0310) | MachineScopeGuard, pack time |
The step appearing in a per-user install, where the containing directory is user-writable by construction |
payload:// refusal |
PrivilegedTargetGuard remarks |
Loading from the user-writable extraction temp directory |
Together these mean the DLL that gets loaded is one an administrator put inside
the installed application. The residual trust is in the publisher, and a
publisher who wanted to run code as administrator during their own install has
run_program and needs no COM DLL to do it. com_register is therefore not an
additional grant of authority to the publisher; it is the same authority the
manifest already carries, exercised through a different verb.
What is accepted as a known limitation¶
- A faulting or hanging
DllRegisterServerkills or wedges the install. There is no timeout and no crash isolation. The journal is on disk before the call, so the state is recoverable, but the run itself is lost. FreeLibrarydoes not undo everything. A DLL that spawned a thread, installed a hook, or patched process state duringDllMain/DllRegisterServerkeeps those effects for the remaining life of the installer process.
What would reverse this decision¶
Any one of these, and this ADR should be superseded:
com_registergains a non-publisher-authored input. Ifpathever becomes reachable from a wizard field, aregistry_readvar, a/P<name>=argument or a downloaded artifact, the DLL is no longer "what the publisher shipped" and provenance stops carrying the argument.- A real sandbox becomes available for the operation. An AppContainer or restricted-token host that can still complete machine-global COM registration — via a broker, or via Windows offering a supported alternative to self-registration — turns alternative B into an actual privilege boundary.
- Crash rates make availability the dominant concern. If real publishers' DLLs are observed faulting during install, alternative B's one genuine benefit becomes the deciding one, and the bitness/exit-code costs become worth paying.
Consequences¶
Positive.
- No new process, no bitness detection, no
regsvr32dependency, no LOLBIN invocation in shipped installers. - The publisher-facing error messages stay specific (load failure with Win32
error / missing export / HRESULT) rather than collapsing into
regsvr32's exit-code soup. - The AOT posture is unchanged and remains the strong one:
[LibraryImport]source-generated stubs plus a statically-bounddelegate* unmanaged[Stdcall]<int>— no reflection, no runtime IL, noMarshal.GetDelegateForFunctionPointer. This is the one AOT-risk step identified in P11 and it stays resolved.
Negative, accepted.
- A malformed publisher DLL can crash the installer. Documented, not mitigated.
- There is no execution timeout on
DllRegisterServer.
Neutral.
- The trust assumption is now stated in
docs/guides/install-steps.mdundercom_register, so a publisher reading the guide sees what the step grants before writing it into a manifest.
Verification¶
ComRegisterStepTestsandPrivilegedStepContainmentTestspin the provenance controls the decision rests on: acom_registerwhosepathescapesinstall_dir, reaches it through a junction, or lands in a non-admin-only-writable directory is refused before the journal entry and before any load.docs/guides/install-steps.md'scom_registersection states the trust assumption and links back to the anchoring rules.- No behavioural change ships with this ADR — that is the point of it. The decision is to keep the current implementation, and the deliverable is the written rationale plus the publisher-facing statement.
Amendment log¶
| Date | Change |
|---|---|
| 2026-08-11 | Initial version. Stage 2, lane S6, register row R36. |
| 2026-09-09 | "What the step does today" gained step 5, the R15 follow-up: the step now withdraws its UnregisterCom record for the LoadFailed / ExportMissing outcomes, where the undo's own DllUnregisterServer cannot be called. No change to the in-process decision this ADR records. |