Skip to content

cli: verify signatures of side-loadable DLLs on Windows (#2411) - #2478

Open
ronaldtse wants to merge 7 commits into
mainfrom
fix-2411-dll-sideload
Open

cli: verify signatures of side-loadable DLLs on Windows (#2411)#2478
ronaldtse wants to merge 7 commits into
mainfrom
fix-2411-dll-sideload

Conversation

@ronaldtse

@ronaldtse ronaldtse commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Addresses Fix rnpkeys dll sideloading vulnerability of rnp.dll #2411: the Windows loader resolves rnp.dll (and any other DLL) from the executable's directory before main() runs, so replacing a DLL next to a signed rnpkeys.exe — as distributed with Thunderbird — executes attacker code inside a process that appears trusted to EDR/AV.
  • Scope note (important): the root cause is the distribution layout (signed exe + writable sibling DLL), which is owned by the distributor — rnp's code contains no untrusted-path loading of its own. This PR is defense-in-depth, not a complete fix: a main()-time check runs after the loader has already mapped the DLLs, so it detects and aborts but cannot prevent the replaced module's DllMain from executing. Complete closure for signed distributions is static linking (BUILD_SHARED_LIBS=OFF), documented in docs/packaging.adoc — Thunderbird has ruled that out due to size, hence this runtime check.
  • Design (reworked after discussion with Thunderbird in Fix rnpkeys dll sideloading vulnerability of rnp.dll #2411 so that embedders need zero build changes):
    • the check lives in src/rnp/fficli.cpp, a file every embedder of the rnp CLI sources (Thunderbird's third_party/rnp moz.build) already compiles into both rnp and rnpkeys — a plain re-vendor picks it up;
    • WinVerifyTrust is resolved at runtime from System32 (LoadLibraryExW + GetProcAddress, action GUIDs instantiated locally via initguid), so no wintrust/crypt32 link-time dependency is added for anyone, and the verification API itself cannot be side-loaded;
    • only modules from the executable's directory that are part of its transitive import chain are verified (walking the in-memory PE import tables) — not every app-dir module. Thunderbird's install directory holds ~100 DLLs that are not rnp's to police, and verifying each would cost seconds per run; the import chain keeps it to rnp.dll (plus any sibling backend DLLs) at tens of milliseconds;
    • the check activates only when the executable itself is signed — unsigned dev builds are unaffected (also keeps CI green) — and fails open with a warning if the verification API is unavailable.
  • Blast-radius analysis (why this hardening should not cause new problems): false positives are limited to the exe's own import chain, which a signed distributor ships signed by construction; revocation is not checked (offline-safe); the only hard-fail condition is signed exe + unsigned/invalid signature in its own import chain, which is precisely the attack signature. Failure modes are fail-open (unavailable API, empty path); the code parses only OS-provided module paths and already-mapped, loader-validated PE headers.

Test plan

  • Windows legs compile and pass (msys2 shared/static + native MSVC) — unsigned binaries exercise the skip path
  • rnp_tests.test_dll_verify_unsigned passes on Windows
  • non-Windows builds unaffected (verified locally: macOS build + CLI smoke test; net diff has no build-system changes)
  • signed-path behavior to be validated by a distributor (e.g. Mozilla) since CI has no signing infrastructure

@codecov

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 85.46%. Comparing base (100cc8c) to head (e2e65d9).

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #2478   +/-   ##
=======================================
  Coverage   85.46%   85.46%           
=======================================
  Files         125      125           
  Lines       22962    22962           
=======================================
  Hits        19625    19625           
  Misses       3337     3337           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The Windows loader resolves rnp.dll and any other DLL from the
executable's directory before main() runs, so replacing a DLL next to a
signed rnpkeys.exe (as shipped with Thunderbird) runs attacker code
inside a trusted-looking process.

rnp and rnpkeys now refuse to continue when a module loaded from the
executable's own directory has no valid Authenticode signature, but only
if the executable itself is signed - unsigned development builds are
unaffected. The check cannot undo the loader-time DLL initialization, so
signed distributions are also advised to build the tools statically
(BUILD_SHARED_LIBS=OFF), documented in docs/packaging.adoc.
WINTRUST_FILE_INFOW and WinVerifyTrustW do not exist - the file-info
struct is wide-only without a suffix and WinVerifyTrust has no A/W
variants; only WINTRUST_DATA is split.
Rework per the discussion in #2411 so that embedders compiling the rnp
CLI sources (Thunderbird's third_party/rnp moz.build) get the protection
with a plain re-vendor and no build configuration changes:

- the check now lives in fficli.cpp, which both rnp and rnpkeys programs
  already compile, instead of a new translation unit;
- WinVerifyTrust is resolved at runtime from System32 via
  LoadLibraryEx + GetProcAddress (GUIDs instantiated via initguid), so
  no wintrust/crypt32 link-time dependency is added for anyone - which
  also makes the verification API itself non-side-loadable;
- only modules from the executable's directory that are part of its
  transitive import chain are verified (walking the in-memory PE import
  tables) instead of every application-directory module: Thunderbird's
  install directory holds around 100 DLLs which are not rnp's to police,
  and verifying each would cost seconds per run;
- unsigned executables still skip the check entirely, and the check
  fails open if the verification API is unavailable.
@ronaldtse
ronaldtse force-pushed the fix-2411-dll-sideload branch from f3d0acf to e2e65d9 Compare September 3, 2026 06:18
@ronaldtse

Copy link
Copy Markdown
Contributor Author

@ni4 your approval here was auto-dismissed when I rebased the branch onto current main (to pick up the #2480 gpg-flake coverage) - the content is otherwise exactly what you reviewed: the fficli-based DLL verification plus the macOS library-validation docs. Could you re-approve when convenient? Everything is green except the fuzzers-openssl red that clears once #2477 lands.

@ronaldtse
ronaldtse requested a review from ni4 September 5, 2026 07:06

@ni4 ni4 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM x2 :)

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.

2 participants