Skip to content

Commit eebc839

Browse files
committed
cli: verify signatures of side-loadable DLLs on Windows (#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 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.
1 parent cc4b4ea commit eebc839

10 files changed

Lines changed: 277 additions & 0 deletions

File tree

‎docs/packaging.adoc‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,39 @@ C project, the C++ runtime must be linked as well: with CMake enable the CXX
4040
language in your project, with manual flags link via the C++ compiler driver
4141
or add the C++ standard library explicitly (`-lc++` or `-lstdc++`).
4242

43+
== Hardening signed Windows distributions (DLL side-loading)
44+
45+
The `rnp` and `rnpkeys` command-line tools are attractive side-loading
46+
targets on Windows when shipped inside a digitally signed application (see
47+
issue #2411): the Windows loader resolves `rnp.dll` (and any other DLL) from
48+
the executable's directory *before* `main()` runs, so replacing a DLL next
49+
to a signed `rnpkeys.exe` results in attacker-controlled code running
50+
inside a process that appears trusted to EDR/AV products.
51+
52+
Two complementary mitigations are available:
53+
54+
. *Prefer static linking for signed distributions.* Building the tools with
55+
`-DBUILD_SHARED_LIBS=OFF` removes the `rnp.dll` import entirely (the CLI
56+
executables statically link librnp), which also closes the
57+
loader-resolves-before-main window for the library itself. This is the
58+
recommended configuration for distributors who sign and ship the
59+
executables.
60+
. *Runtime module verification (default on Windows).* On startup, `rnp` and
61+
`rnpkeys` check every module loaded from the executable's own directory
62+
for a valid Authenticode signature, but only when the executable itself is
63+
signed — unsigned development builds are unaffected. If an unsigned or
64+
invalidly signed module is found, the tool refuses to continue with a
65+
clear error message. Note that a startup check runs after the loader has
66+
already mapped the DLLs, so it detects the substitution and aborts before
67+
the tool is abused further, but cannot fully prevent execution of the
68+
replaced module's initialization code — hence the static-linking
69+
recommendation above.
70+
71+
Projects embedding the rnp sources directly (rather than building through
72+
rnp's CMake) must compile `src/common/dll-verify.cpp` into the CLI
73+
executables on Windows and link `crypt32` and `wintrust` (see
74+
Thunderbird's `third_party/rnp` `moz.build` setup).
75+
4376
== CMake find_package
4477

4578
The installed CMake package (`<prefix>/lib/cmake/rnp/rnp-config.cmake`)

‎src/common/CMakeLists.txt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ add_library(rnp-common OBJECT
2828
time-utils.cpp
2929
)
3030

31+
if(WIN32)
32+
target_sources(rnp-common PRIVATE dll-verify.cpp)
33+
endif()
34+
3135
if(MSVC)
3236
find_path(GETOPT_INCLUDE_DIR
3337
NAMES getopt.h

‎src/common/dll-verify.cpp‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/*
2+
* Copyright (c) 2026 [Ribose Inc](https://www.ribose.com).
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without modification,
6+
* are permitted provided that the following conditions are met:
7+
*
8+
* 1. Redistributions of source code must retain the above copyright notice,
9+
* this list of conditions and the following disclaimer.
10+
*
11+
* 2. Redistributions in binary form must reproduce the above copyright notice,
12+
* this list of conditions and the following disclaimer in the documentation
13+
* and/or other materials provided with the distribution.
14+
*
15+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
16+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
17+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
19+
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
20+
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21+
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22+
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
23+
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
24+
* THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
25+
*/
26+
27+
#include <windows.h>
28+
#include <wintrust.h>
29+
#include <softpub.h>
30+
#include <tlhelp32.h>
31+
32+
#include <stdio.h>
33+
#include <string>
34+
35+
#include "dll-verify.h"
36+
37+
namespace {
38+
39+
/* Checks whether the file has a valid embedded Authenticode signature which
40+
* chains to a trusted root. Revocation is not checked so the verification
41+
* also works on offline systems. */
42+
bool
43+
file_has_valid_signature(const std::wstring &path)
44+
{
45+
WINTRUST_FILE_INFOW file = {};
46+
file.cbStruct = sizeof(file);
47+
file.pcwszFilePath = path.c_str();
48+
49+
GUID policy = WINTRUST_ACTION_GENERIC_VERIFY_V2;
50+
WINTRUST_DATAW data = {};
51+
data.cbStruct = sizeof(data);
52+
data.dwUIChoice = WTD_UI_NONE;
53+
data.fdwRevocationChecks = WTD_REVOCATION_CHECK_NONE;
54+
data.dwUnionChoice = WTD_CHOICE_FILE;
55+
data.pFile = &file;
56+
data.dwStateAction = WTD_STATEACTION_VERIFY;
57+
58+
HWND dummy = static_cast<HWND>(INVALID_HANDLE_VALUE);
59+
LONG res = WinVerifyTrustW(dummy, &policy, &data);
60+
data.dwStateAction = WTD_STATEACTION_CLOSE;
61+
WinVerifyTrustW(dummy, &policy, &data);
62+
return res == ERROR_SUCCESS;
63+
}
64+
65+
std::wstring
66+
directory_of(const std::wstring &path)
67+
{
68+
size_t pos = path.find_last_of(L"\\/");
69+
return (pos == std::wstring::npos) ? std::wstring() : path.substr(0, pos);
70+
}
71+
72+
bool
73+
path_in_directory(const std::wstring &path, const std::wstring &dir)
74+
{
75+
if (dir.empty() || path.size() <= dir.size() + 1) {
76+
return false;
77+
}
78+
if (_wcsnicmp(path.c_str(), dir.c_str(), dir.size()) != 0) {
79+
return false;
80+
}
81+
return path[dir.size()] == L'\\' || path[dir.size()] == L'/';
82+
}
83+
84+
} // namespace
85+
86+
bool
87+
rnp_dll_verify_modules()
88+
{
89+
wchar_t exepath[32768] = {0};
90+
if (!GetModuleFileNameW(NULL, exepath, 32768)) {
91+
return true;
92+
}
93+
std::wstring exe(exepath);
94+
/* Unsigned executable is a development build: a swapped DLL does not gain
95+
* any trusted-process status, so there is nothing to enforce. */
96+
if (!file_has_valid_signature(exe)) {
97+
return true;
98+
}
99+
100+
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, GetCurrentProcessId());
101+
if (snap == INVALID_HANDLE_VALUE) {
102+
fwprintf(stderr, L"warning: could not enumerate loaded modules.\n");
103+
return true;
104+
}
105+
106+
std::wstring exedir = directory_of(exe);
107+
bool trusted = true;
108+
MODULEENTRY32W entry = {};
109+
entry.dwSize = sizeof(entry);
110+
if (Module32FirstW(snap, &entry)) {
111+
do {
112+
std::wstring path(entry.szExePath);
113+
if (path == exe || !path_in_directory(path, exedir)) {
114+
continue;
115+
}
116+
if (!file_has_valid_signature(path)) {
117+
fwprintf(stderr,
118+
L"error: module '%s' is loaded from the application directory "
119+
L"but has no valid digital signature - possible DLL "
120+
L"side-loading. Refusing to continue.\n",
121+
path.c_str());
122+
trusted = false;
123+
}
124+
} while (Module32NextW(snap, &entry));
125+
}
126+
CloseHandle(snap);
127+
return trusted;
128+
}

‎src/common/dll-verify.h‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Copyright (c) 2026 [Ribose Inc](https://www.ribose.com).
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without modification,
6+
* are permitted provided that the following conditions are met:
7+
*
8+
* 1. Redistributions of source code must retain the above copyright notice,
9+
* this list of conditions and the following disclaimer.
10+
*
11+
* 2. Redistributions in binary form must reproduce the above copyright notice,
12+
* this list of conditions and the following disclaimer in the documentation
13+
* and/or other materials provided with the distribution.
14+
*
15+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
16+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
17+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
19+
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
20+
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21+
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22+
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
23+
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
24+
* THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
25+
*/
26+
27+
#ifndef DLL_VERIFY_H_
28+
#define DLL_VERIFY_H_
29+
30+
#if defined(_WIN32)
31+
/* DLL side-loading protection (issue #2411).
32+
*
33+
* Verifies that every module loaded from the executable's own directory
34+
* carries a valid Authenticode signature, but only when the executable
35+
* itself is signed - unsigned development builds are skipped. Returns
36+
* false (after printing a diagnostic) when an unsigned or badly signed
37+
* module was found, i.e. a likely side-loaded replacement. */
38+
bool rnp_dll_verify_modules();
39+
#endif
40+
41+
#endif

‎src/rnp/CMakeLists.txt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ target_link_libraries(rnp
8484
PRIVATE
8585
librnp
8686
)
87+
if(WIN32)
88+
target_link_libraries(rnp PRIVATE crypt32 wintrust)
89+
endif()
8790
if(MSVC)
8891
target_link_libraries(rnp
8992
PRIVATE

‎src/rnp/rnp.cpp‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@
4949
#include <errno.h>
5050

5151
#include "fficli.h"
52+
#if defined(_WIN32)
53+
#include "common/dll-verify.h"
54+
#endif
5255
#include "str-utils.h"
5356
#include "logging.h"
5457

@@ -641,6 +644,12 @@ int
641644
rnp_main(int argc, char **argv)
642645
#endif
643646
{
647+
#if !defined(RNP_RUN_TESTS) && defined(_WIN32)
648+
if (!rnp_dll_verify_modules()) {
649+
return EXIT_ERROR;
650+
}
651+
#endif
652+
644653
if (argc < 2) {
645654
print_usage(usage);
646655
return EXIT_ERROR;

‎src/rnpkeys/CMakeLists.txt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ target_link_libraries(rnpkeys
8585
PRIVATE
8686
librnp
8787
)
88+
if(WIN32)
89+
target_link_libraries(rnpkeys PRIVATE crypt32 wintrust)
90+
endif()
8891
if(MSVC)
8992
target_link_libraries(rnpkeys
9093
PRIVATE

‎src/rnpkeys/main.cpp‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
#include <stdio.h>
3838
#include <string.h>
3939
#include "rnpkeys.h"
40+
#if defined(_WIN32)
41+
#include "common/dll-verify.h"
42+
#endif
4043

4144
extern struct option options[];
4245
extern const char * usage;
@@ -67,6 +70,12 @@ int
6770
rnpkeys_main(int argc, char **argv)
6871
#endif
6972
{
73+
#if !defined(RNP_RUN_TESTS) && defined(_WIN32)
74+
if (!rnp_dll_verify_modules()) {
75+
return EXIT_FAILURE;
76+
}
77+
#endif
78+
7079
if (argc < 2) {
7180
print_usage(usage);
7281
return EXIT_FAILURE;

‎src/tests/CMakeLists.txt‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ set(RNP_TEST_SOURCES
113113
ffi-key-prop.cpp
114114
ffi-key.cpp
115115
file-utils.cpp
116+
dll-verify.cpp
116117
generatekey.cpp
117118
kbx-nsigs-test.cpp
118119
key-add-userid.cpp
@@ -191,6 +192,13 @@ if(MSVC)
191192
"${GETOPT_LIBRARY}"
192193
)
193194
endif()
195+
if(WIN32)
196+
target_link_libraries(rnp_tests
197+
PRIVATE
198+
crypt32
199+
wintrust
200+
)
201+
endif()
194202
if (CRYPTO_BACKEND_BOTAN)
195203
get_target_property(BOTAN_INCLUDE_DIRS Botan::Botan INTERFACE_INCLUDE_DIRECTORIES)
196204
endif()

‎src/tests/dll-verify.cpp‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright (c) 2026 [Ribose Inc](https://www.ribose.com).
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without modification,
6+
* are permitted provided that the following conditions are met:
7+
*
8+
* 1. Redistributions of source code must retain the above copyright notice,
9+
* this list of conditions and the following disclaimer.
10+
*
11+
* 2. Redistributions in binary form must reproduce the above copyright notice,
12+
* this list of conditions and the following disclaimer in the documentation
13+
* and/or other materials provided with the distribution.
14+
*
15+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
16+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
17+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
19+
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
20+
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21+
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22+
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
23+
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
24+
* THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
25+
*/
26+
27+
#include "rnp_tests.h"
28+
29+
#if defined(_WIN32)
30+
#include "common/dll-verify.h"
31+
32+
TEST_F(rnp_tests, test_dll_verify_unsigned)
33+
{
34+
/* CI and development builds are unsigned, so the module verification
35+
* must succeed as a no-op (the check only activates for signed
36+
* executables). */
37+
assert_true(rnp_dll_verify_modules());
38+
}
39+
#endif

0 commit comments

Comments
 (0)