[Lldb-commits] [lldb] [llvm] [lldb] Add HTTPS hardening tests for SymbolLocatorSymStore (PR #192274)
Stefan Gränitz via lldb-commits
lldb-commits at lists.llvm.org
Fri Apr 17 03:39:14 PDT 2026
https://github.com/weliveindetail updated https://github.com/llvm/llvm-project/pull/192274
>From f49305e6fb7b46182564b609af24fa6e992f12f5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Stefan=20Gr=C3=A4nitz?= <stefan.graenitz at gmail.com>
Date: Wed, 15 Apr 2026 13:08:27 +0200
Subject: [PATCH 1/2] [lldb] Add HTTPS hardening tests for
SymbolLocatorSymStore
---
.../SymStore/SymbolLocatorSymStore.cpp | 22 ++-
.../SymbolLocatorSymStoreProperties.td | 3 +
lldb/test/API/symstore/TestSymStore.py | 177 +++++++++++++++++-
llvm/include/llvm/HTTP/HTTPClient.h | 4 +
llvm/lib/HTTP/CMakeLists.txt | 2 +-
llvm/lib/HTTP/HTTPClient.cpp | 66 ++++++-
6 files changed, 266 insertions(+), 8 deletions(-)
diff --git a/lldb/source/Plugins/SymbolLocator/SymStore/SymbolLocatorSymStore.cpp b/lldb/source/Plugins/SymbolLocator/SymStore/SymbolLocatorSymStore.cpp
index 1344232917b33..86315c0305ccf 100644
--- a/lldb/source/Plugins/SymbolLocator/SymStore/SymbolLocatorSymStore.cpp
+++ b/lldb/source/Plugins/SymbolLocator/SymStore/SymbolLocatorSymStore.cpp
@@ -58,6 +58,25 @@ class PluginProperties : public Properties {
m_collection_sp->GetPropertyAtIndexAsArgs(ePropertySymStoreURLs, urls);
return urls;
}
+
+ std::optional<std::string> GetTLSCertFingerprint() const {
+ OptionValueString *s =
+ m_collection_sp->GetPropertyAtIndexAsOptionValueString(
+ ePropertyTLSCertFingerprint);
+ if (!s)
+ return {};
+ llvm::StringRef val = s->GetCurrentValueAsRef();
+ if (val.empty())
+ return {};
+ if (val.size() != 64 || !llvm::all_of(val, llvm::isHexDigit)) {
+ Debugger::ReportWarning(llvm::formatv(
+ "plugin.symbol-locator.symstore.tls-cert-fingerprint: expected a "
+ "64-character hex string (SHA-256), but got '{0}', ignoring",
+ val));
+ return {};
+ }
+ return val.lower();
+ }
};
} // namespace
@@ -66,7 +85,6 @@ static PluginProperties &GetGlobalPluginProperties() {
static PluginProperties g_settings;
return g_settings;
}
-
SymbolLocatorSymStore::SymbolLocatorSymStore() : SymbolLocator() {}
void SymbolLocatorSymStore::Initialize() {
@@ -184,6 +202,8 @@ RequestFileFromSymStoreServerHTTP(llvm::StringRef base_url, llvm::StringRef key,
client);
llvm::HTTPRequest request(source_url);
+ request.PinnedCertFingerprint =
+ GetGlobalPluginProperties().GetTLSCertFingerprint();
if (llvm::Error Err = client.perform(request, Handler)) {
Debugger::ReportWarning(
llvm::formatv("failed to download from SymStore '{0}': {1}", source_url,
diff --git a/lldb/source/Plugins/SymbolLocator/SymStore/SymbolLocatorSymStoreProperties.td b/lldb/source/Plugins/SymbolLocator/SymStore/SymbolLocatorSymStoreProperties.td
index 0cd631a80b90b..7d9b1cbae5f8e 100644
--- a/lldb/source/Plugins/SymbolLocator/SymStore/SymbolLocatorSymStoreProperties.td
+++ b/lldb/source/Plugins/SymbolLocator/SymStore/SymbolLocatorSymStoreProperties.td
@@ -4,4 +4,7 @@ let Definition = "symbollocatorsymstore", Path = "plugin.symbol-locator.symstore
def SymStoreURLs : Property<"urls", "Array">,
ElementType<"String">,
Desc<"List of local symstore directories to query for symbols">;
+ def TLSCertFingerprint : Property<"tls-cert-fingerprint", "String">,
+ DefaultStringValue<"">,
+ Desc<"SHA-256 fingerprint (lowercase hex, no separators) of the symbol server's TLS certificate. When set, LLDB will accept an HTTPS server that presents this self-signed certificate even if it is not trusted by the system certificate store (Windows only).">;
}
diff --git a/lldb/test/API/symstore/TestSymStore.py b/lldb/test/API/symstore/TestSymStore.py
index b5c8fbb58324f..34c0ac186f49b 100644
--- a/lldb/test/API/symstore/TestSymStore.py
+++ b/lldb/test/API/symstore/TestSymStore.py
@@ -1,7 +1,11 @@
+import datetime
import http.server
+import ipaddress
import os
import shutil
import socketserver
+import ssl
+import sys
import threading
from functools import partial
@@ -90,6 +94,119 @@ def __exit__(self, *exc_info):
self._thread.join()
+class HTTPSServer:
+ """
+ Context Manager to serve a local directory tree via HTTPS.
+ """
+
+ class ErrorAwareTCPServer(socketserver.ThreadingTCPServer):
+ """TCP layer that will suppress errors of the given type."""
+
+ def __init__(self, address, handler, err):
+ self.err = err
+ super().__init__(address, handler)
+
+ def handle_error(self, request, client_address):
+ if isinstance(sys.exc_info()[1], self.err):
+ return
+ super().handle_error(request, client_address)
+
+ def __init__(self, dir=None, handler=None, cert=None):
+ if handler is None:
+ handler = partial(http.server.SimpleHTTPRequestHandler, directory=dir)
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+ ctx.load_cert_chain(cert.file, cert.key_file)
+ address = ("localhost", 0) # auto-select free port
+ self._server = self.ErrorAwareTCPServer(address, handler, ssl.SSLError)
+ self._server.socket = ctx.wrap_socket(self._server.socket, server_side=True)
+ self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
+
+ def __enter__(self):
+ self._thread.start()
+ host, port = self._server.server_address
+ return f"https://{host}:{port}"
+
+ def __exit__(self, *exc_info):
+ if self._server:
+ self._server.shutdown()
+ self._server.server_close()
+ if self._thread:
+ self._thread.join()
+
+
+# FIXME: Drop this once the package is rolled out on all build bots
+def skipUnlessPackageAvailable(name):
+ available = True
+ try:
+ __import__(name)
+ except ImportError:
+ available = False
+ import unittest
+
+ return unittest.skipUnless(available, f"requires the '{name}' package")
+
+
+class SelfSignedCert:
+ """
+ Self-signed cert/key pair for localhost.
+ """
+
+ def __init__(self, tmpdir):
+ from cryptography import x509
+ from cryptography.x509.oid import NameOID
+ from cryptography.hazmat.primitives import hashes, serialization
+ from cryptography.hazmat.primitives.asymmetric import rsa
+
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])
+ now = datetime.datetime.now(datetime.timezone.utc)
+ cert = (
+ x509.CertificateBuilder()
+ .subject_name(name)
+ .issuer_name(name)
+ .public_key(key.public_key())
+ .serial_number(x509.random_serial_number())
+ .not_valid_before(now)
+ .not_valid_after(now + datetime.timedelta(days=1))
+ .add_extension(
+ x509.SubjectAlternativeName(
+ [
+ x509.DNSName("localhost"),
+ x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
+ ]
+ ),
+ critical=False,
+ )
+ .sign(key, hashes.SHA256())
+ )
+ os.makedirs(tmpdir, exist_ok=False)
+ self.key_file = os.path.join(tmpdir, "server.key")
+ self.file = os.path.join(tmpdir, "server.crt")
+ with open(self.key_file, "wb") as f:
+ f.write(
+ key.private_bytes(
+ serialization.Encoding.PEM,
+ serialization.PrivateFormat.TraditionalOpenSSL,
+ serialization.NoEncryption(),
+ )
+ )
+ with open(self.file, "wb") as f:
+ f.write(cert.public_bytes(serialization.Encoding.PEM))
+ self.fingerprint = cert.fingerprint(hashes.SHA256()).hex()
+
+
+class RedirectHandler(http.server.BaseHTTPRequestHandler):
+ base_url = None
+
+ def do_GET(self):
+ self.send_response(301)
+ self.send_header("Location", self.base_url + self.path)
+ self.end_headers()
+
+ def log_message(self, *args):
+ pass # suppress request logs
+
+
class SymStoreTests(TestBase):
TEST_WITH_PDB_DEBUG_INFO = True
@@ -157,8 +274,6 @@ def test_http_not_found(self):
warnings = err_file.read().decode()
self.assertEqual(warnings, "")
- # TODO: Add test coverage for common HTTPS security scenarios, e.g. self-signed
- # certs, non-HTTPS redirects, etc.
def test_http(self):
"""
Check that breakpoint hits with remote SymStore.
@@ -168,3 +283,61 @@ def test_http(self):
with HTTPServer(dir) as url:
self.runCmd(f"settings set plugin.symbol-locator.symstore.urls {url}")
self.try_breakpoint(exe, should_have_loc=True)
+
+ @skipUnlessPackageAvailable("cryptography")
+ def test_https(self):
+ """
+ Check that breakpoint resolves with remote SymStore via HTTPS.
+ """
+ exe, sym = self.build_inferior()
+ with MockedSymStore(self, exe, sym) as symstore_dir:
+ cert = SelfSignedCert(self.getBuildArtifact("cert"))
+ with HTTPSServer(dir=symstore_dir, cert=cert) as https_url:
+ # We accept only the self-signed certificate with this fingerprint
+ self.runCmd(
+ f"settings set plugin.symbol-locator.symstore.tls-cert-fingerprint {cert.fingerprint}"
+ )
+ self.runCmd(
+ f"settings set plugin.symbol-locator.symstore.urls {https_url}"
+ )
+ self.try_breakpoint(exe, should_have_loc=True)
+
+ @skipUnlessPackageAvailable("cryptography")
+ def test_https_reject_selfsigned_cert(self):
+ """
+ Check that LLDB rejects an HTTPS server with an untrusted self-signed cert.
+ """
+ exe, sym = self.build_inferior()
+ with MockedSymStore(self, exe, sym) as symstore_dir:
+ cert = SelfSignedCert(self.getBuildArtifact("cert"))
+ with HTTPSServer(dir=symstore_dir, cert=cert) as https_url:
+ # No fingerprint set
+ self.runCmd(
+ f"settings set plugin.symbol-locator.symstore.urls {https_url}"
+ )
+ self.try_breakpoint(exe, should_have_loc=False)
+ # Incorrect fingerprint set
+ bogus = "DEADBEEFCAFEBABE"
+ self.runCmd(
+ f"settings set plugin.symbol-locator.symstore.tls-cert-fingerprint {bogus}{bogus}{bogus}{bogus}"
+ )
+ self.try_breakpoint(exe, should_have_loc=False)
+
+ @skipUnlessPackageAvailable("cryptography")
+ def test_https_reject_redirect_http(self):
+ """
+ Check that LLDB does not retrieve symbols from servers that redirect with security downgrades.
+ """
+ exe, sym = self.build_inferior()
+ with MockedSymStore(self, exe, sym) as symstore_dir:
+ with HTTPServer(symstore_dir) as http_url:
+ RedirectHandler.base_url = http_url
+ cert = SelfSignedCert(self.getBuildArtifact("cert"))
+ with HTTPSServer(handler=RedirectHandler, cert=cert) as https_url:
+ self.runCmd(
+ f"settings set plugin.symbol-locator.symstore.urls {https_url}"
+ )
+ self.runCmd(
+ f"settings set plugin.symbol-locator.symstore.tls-cert-fingerprint {cert.fingerprint}"
+ )
+ self.try_breakpoint(exe, should_have_loc=False)
diff --git a/llvm/include/llvm/HTTP/HTTPClient.h b/llvm/include/llvm/HTTP/HTTPClient.h
index a2ba4251d35ce..a3db41c1a9034 100644
--- a/llvm/include/llvm/HTTP/HTTPClient.h
+++ b/llvm/include/llvm/HTTP/HTTPClient.h
@@ -21,6 +21,7 @@
#include "llvm/Support/MemoryBuffer.h"
#include <chrono>
+#include <optional>
namespace llvm {
@@ -31,7 +32,10 @@ struct HTTPRequest {
SmallString<128> Url;
SmallVector<std::string, 0> Headers;
HTTPMethod Method = HTTPMethod::GET;
+ // Follow redirects without security downgrades.
bool FollowRedirects = true;
+ // Allow self-signed TLS certificates with this SHA-256 (WinHTTP only).
+ std::optional<std::string> PinnedCertFingerprint;
HTTPRequest(StringRef Url);
};
diff --git a/llvm/lib/HTTP/CMakeLists.txt b/llvm/lib/HTTP/CMakeLists.txt
index e3b71434333a1..9dab6ad200066 100644
--- a/llvm/lib/HTTP/CMakeLists.txt
+++ b/llvm/lib/HTTP/CMakeLists.txt
@@ -10,7 +10,7 @@ endif()
# Use WinHTTP on Windows
if (WIN32)
- set(imported_libs ${imported_libs} winhttp.lib)
+ set(imported_libs ${imported_libs} winhttp.lib crypt32.lib)
endif()
# This is no component library so the LLVM dylib does not gain a dependency on curl
diff --git a/llvm/lib/HTTP/HTTPClient.cpp b/llvm/lib/HTTP/HTTPClient.cpp
index 8517c8cc7c7a0..c69aecfa7d619 100644
--- a/llvm/lib/HTTP/HTTPClient.cpp
+++ b/llvm/lib/HTTP/HTTPClient.cpp
@@ -33,7 +33,8 @@ HTTPRequest::HTTPRequest(StringRef Url) { this->Url = Url.str(); }
bool operator==(const HTTPRequest &A, const HTTPRequest &B) {
return A.Url == B.Url && A.Method == B.Method &&
- A.FollowRedirects == B.FollowRedirects;
+ A.FollowRedirects == B.FollowRedirects &&
+ A.PinnedCertFingerprint == B.PinnedCertFingerprint;
}
HTTPResponseHandler::~HTTPResponseHandler() = default;
@@ -144,8 +145,13 @@ unsigned HTTPClient::responseCode() {
#else
#ifdef _WIN32
+
+// We cannot sort these headers alphabetically.
+// clang-format off
#include <windows.h>
+#include <wincrypt.h>
#include <winhttp.h>
+// clang-format on
namespace {
@@ -236,6 +242,41 @@ void HTTPClient::setTimeout(std::chrono::milliseconds Timeout) {
}
}
+static Error VerifyTLSCertWinHTTP(HINTERNET RequestHandle,
+ const std::string &PinnedFingerprint) {
+ // Decode the expected fingerprint from hex into binary.
+ BYTE Expected[32];
+ DWORD ExpectedSize = sizeof(Expected);
+ if (!CryptStringToBinaryA(
+ PinnedFingerprint.c_str(), (DWORD)PinnedFingerprint.size(),
+ CRYPT_STRING_HEXRAW, Expected, &ExpectedSize, nullptr, nullptr))
+ return createStringError(errc::invalid_argument,
+ "Invalid certificate fingerprint format");
+
+ // Retrieve the server certificate and compute its SHA-256 hash.
+ PCCERT_CONTEXT CertCtx = nullptr;
+ DWORD CertCtxSize = sizeof(CertCtx);
+ if (!WinHttpQueryOption(RequestHandle, WINHTTP_OPTION_SERVER_CERT_CONTEXT,
+ &CertCtx, &CertCtxSize))
+ return createStringError(errc::io_error,
+ "Failed to retrieve server certificate");
+
+ std::array<BYTE, 32> Actual;
+ DWORD ActualSize = Actual.size();
+ bool GotHash = CertGetCertificateContextProperty(
+ CertCtx, CERT_SHA256_HASH_PROP_ID, Actual.data(), &ActualSize);
+ CertFreeCertificateContext(CertCtx);
+ if (!GotHash)
+ return createStringError(errc::io_error,
+ "Failed to compute certificate fingerprint");
+
+ if (memcmp(Actual.data(), Expected, Actual.size()) != 0)
+ return createStringError(errc::permission_denied,
+ "Certificate fingerprint mismatch");
+
+ return Error::success();
+}
+
Error HTTPClient::perform(const HTTPRequest &Request,
HTTPResponseHandler &Handler) {
if (Request.Method != HTTPMethod::GET)
@@ -272,6 +313,14 @@ Error HTTPClient::perform(const HTTPRequest &Request,
&SecureProtocols, sizeof(SecureProtocols)))
return createStringError(errc::io_error, "Failed to set secure protocols");
+ // Disallow redirects in general or HTTPS to HTTP only.
+ DWORD RedirectPolicy = WINHTTP_OPTION_REDIRECT_POLICY_DISALLOW_HTTPS_TO_HTTP;
+ if (!Request.FollowRedirects)
+ RedirectPolicy = WINHTTP_OPTION_REDIRECT_POLICY_NEVER;
+ if (!WinHttpSetOption(Session->SessionHandle, WINHTTP_OPTION_REDIRECT_POLICY,
+ &RedirectPolicy, sizeof(RedirectPolicy)))
+ return createStringError(errc::io_error, "Failed to set redirect policy");
+
// Use HTTP/2 if available
DWORD EnableHttp2 = WINHTTP_PROTOCOL_FLAG_HTTP2;
WinHttpSetOption(Session->SessionHandle, WINHTTP_OPTION_ENABLE_HTTP_PROTOCOL,
@@ -296,6 +345,7 @@ Error HTTPClient::perform(const HTTPRequest &Request,
if (!Session->RequestHandle)
return createStringError(errc::io_error, "Failed to open HTTP request");
+ DWORD SecurityFlags = 0;
if (Secure) {
// Enforce checks that certificate wasn't revoked.
DWORD EnableRevocationChecks = WINHTTP_ENABLE_SSL_REVOCATION;
@@ -305,9 +355,11 @@ Error HTTPClient::perform(const HTTPRequest &Request,
return createStringError(
errc::io_error, "Failed to enable certificate revocation checks");
- // Explicitly enforce default validation. This protects against insecure
- // overrides like SECURITY_FLAG_IGNORE_UNKNOWN_CA.
- DWORD SecurityFlags = 0;
+ // Bypass certificate chain validation with pinned certificates so
+ // that self-signed certificates are accepted at the WinHTTP level. Manual
+ // verification happens right after receiving the response.
+ if (Request.PinnedCertFingerprint)
+ SecurityFlags = (SecurityFlags | SECURITY_FLAG_IGNORE_UNKNOWN_CA);
if (!WinHttpSetOption(Session->RequestHandle, WINHTTP_OPTION_SECURITY_FLAGS,
&SecurityFlags, sizeof(SecurityFlags)))
return createStringError(errc::io_error,
@@ -333,6 +385,12 @@ Error HTTPClient::perform(const HTTPRequest &Request,
if (!WinHttpReceiveResponse(Session->RequestHandle, nullptr))
return createStringError(errc::io_error, "Failed to receive HTTP response");
+ // Verify the server certificate fingerprint if one was pinned.
+ if ((SecurityFlags & SECURITY_FLAG_IGNORE_UNKNOWN_CA) != 0)
+ if (Error Err = VerifyTLSCertWinHTTP(Session->RequestHandle,
+ *Request.PinnedCertFingerprint))
+ return Err;
+
// Get response code
DWORD CodeSize = sizeof(Session->ResponseCode);
if (!WinHttpQueryHeaders(Session->RequestHandle,
>From 2490b5ca55e97c2c734026c2abcb573c041e1171 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Stefan=20Gr=C3=A4nitz?= <stefan.graenitz at gmail.com>
Date: Fri, 17 Apr 2026 12:37:19 +0200
Subject: [PATCH 2/2] Move skipUnlessPackageAvailable() to
lldbsuite/test/decorators.py
---
lldb/packages/Python/lldbsuite/test/decorators.py | 11 +++++++++++
lldb/test/API/symstore/TestSymStore.py | 12 ------------
2 files changed, 11 insertions(+), 12 deletions(-)
diff --git a/lldb/packages/Python/lldbsuite/test/decorators.py b/lldb/packages/Python/lldbsuite/test/decorators.py
index 1ea984aa235cb..bc2b4e75ac067 100644
--- a/lldb/packages/Python/lldbsuite/test/decorators.py
+++ b/lldb/packages/Python/lldbsuite/test/decorators.py
@@ -1366,3 +1366,14 @@ def can_build_and_run_arm64e():
return None
return skipTestIfFn(can_build_and_run_arm64e)(func)
+
+
+def skipUnlessPackageAvailable(name):
+ """Skip the test case if the named package is not available on the system."""
+ available = True
+ try:
+ __import__(name)
+ except ImportError:
+ available = False
+
+ return unittest.skipUnless(available, f"requires the '{name}' package")
diff --git a/lldb/test/API/symstore/TestSymStore.py b/lldb/test/API/symstore/TestSymStore.py
index 34c0ac186f49b..a249d5cf9ac54 100644
--- a/lldb/test/API/symstore/TestSymStore.py
+++ b/lldb/test/API/symstore/TestSymStore.py
@@ -134,18 +134,6 @@ def __exit__(self, *exc_info):
self._thread.join()
-# FIXME: Drop this once the package is rolled out on all build bots
-def skipUnlessPackageAvailable(name):
- available = True
- try:
- __import__(name)
- except ImportError:
- available = False
- import unittest
-
- return unittest.skipUnless(available, f"requires the '{name}' package")
-
-
class SelfSignedCert:
"""
Self-signed cert/key pair for localhost.
More information about the lldb-commits
mailing list