Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile.am
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ DIST_SHARE = \

BIN_CHECKS=$(top_srcdir)/contrib/guix/symbol-check.py \
$(top_srcdir)/contrib/guix/security-check.py \
$(top_srcdir)/contrib/guix/stdlib-path-check.py \
$(top_srcdir)/contrib/devtools/utils.py

WINDOWS_PACKAGING = $(top_srcdir)/share/pixmaps/dash.ico \
Expand Down
7 changes: 7 additions & 0 deletions ci/dash/build_src.sh
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ bash -c "${MAYBE_BEAR} ${MAYBE_TOKEN} make ${MAKEJOBS} ${GOAL}" || ( echo "Build

ccache --version | head -n 1 && ccache --show-stats

# A standard library header path in an executable means a source location was
# captured inside one, which both misreports where a failure came from and, on
# darwin, embeds a path that differs between builders.
if [ "${RUN_STDLIB_PATH_CHECK}" = "true" ]; then
make -C src --jobs=1 check-stdlib-paths
fi

if [ -n "$USE_VALGRIND" ]; then
echo "valgrind in USE!"
"${BASE_ROOT_DIR}/ci/test/wrap-valgrind.sh"
Expand Down
5 changes: 5 additions & 0 deletions ci/test/00_setup_env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ export USE_BUSY_BOX=${USE_BUSY_BOX:-false}
export RUN_UNIT_TESTS=${RUN_UNIT_TESTS:-true}
export RUN_FUNCTIONAL_TESTS=${RUN_FUNCTIONAL_TESTS:-true}
export RUN_TIDY=${RUN_TIDY:-false}
# Whether to check the built executables for embedded standard library
# header paths. Off by default: sanitizer and fuzz builds record source
# locations for their own diagnostics, so they are expected to name
# standard library headers legitimately.
export RUN_STDLIB_PATH_CHECK=${RUN_STDLIB_PATH_CHECK:-false}
# By how much to scale the test_runner timeouts (option --timeout-factor).
# This is needed because some ci machines have slow CPU or disk, so sanitizers
# might be slow or a reindex might be waiting on disk IO.
Expand Down
1 change: 1 addition & 0 deletions ci/test/00_setup_env_mac.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ export RUN_UNIT_TESTS=false
export RUN_FUNCTIONAL_TESTS=false
export GOAL="all deploy"
export BITCOIN_CONFIG="--with-gui --enable-reduce-exports --disable-miner"
export RUN_STDLIB_PATH_CHECK=true
1 change: 1 addition & 0 deletions ci/test/00_setup_env_native_qt5.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ export RUN_UNIT_TESTS="false"
export GOAL="install"
export DOWNLOAD_PREVIOUS_RELEASES="true"
export BITCOIN_CONFIG="--enable-zmq --with-libs=no --enable-reduce-exports CPPFLAGS='-DBOOST_MULTI_INDEX_ENABLE_SAFE_MODE' LDFLAGS=-static-libstdc++"
export RUN_STDLIB_PATH_CHECK=true
2 changes: 2 additions & 0 deletions contrib/guix/libexec/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,8 @@ mkdir -p "$DISTSRC"
make -C src --jobs=1 check-security ${V:+V=1}
# Check that executables only contain allowed version symbols.
make -C src --jobs=1 check-symbols ${V:+V=1}
# Check that no executable embeds a standard library header path.
make -C src --jobs=1 check-stdlib-paths ${V:+V=1}

mkdir -p "$OUTDIR"

Expand Down
95 changes: 95 additions & 0 deletions contrib/guix/stdlib-path-check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
# Copyright (c) 2026 The Dash Core developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
'''
Check that no executable embeds the path of a C++ standard library header.

Such a path means a source location was captured inside a standard library
header instead of at the call site. gsl::not_null is the way this happens here:
its constructor defaults a nostd::source_location argument, so constructing one
through a forwarding wrapper - std::stack::emplace(), std::optional::emplace(),
std::make_unique() - records the wrapper's header rather than our own code.

That costs us twice. gsl::details::terminate() prints the location, so a failed
precondition names a standard library internal instead of the code that broke
it. And the path is the toolchain's, so on darwin it comes from the macOS SDK,
whose location differs between builders and makes the release binaries
irreproducible.

Only the sections that hold string literals are examined. DWARF legitimately
names standard library headers for inlined template code, and identifying
those sections by name is not portable - PE stores a long section name as an
offset into the string table, so they appear as '/81' rather than '.debug_*'.
Naming the sections we want instead cannot pick up debug data by accident.

Exit status will be 0 if successful, and the program will be silent.
Otherwise the exit status will be 1 and it will log which executables embed
which paths, and the section each was found in - which is what tells you
whether a hit is a string literal or debug data this script failed to skip.

Example usage:

find ../path/to/binaries -type f -executable | xargs python3 contrib/guix/stdlib-path-check.py
'''
import re
import sys

import lief

# Matches the include directory of both libc++ (include/c++/v1) and libstdc++
# (include/c++/<version>), wherever the toolchain or sysroot places it.
STDLIB_INCLUDE = re.compile(rb'[\x20-\x7e]*include/c\+\+/[\x20-\x7e]*')
Comment on lines +40 to +42

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

馃煛 Suggestion: Regex misses Debian multiarch stdlib header paths

The pattern requires the contiguous substring include/c++/, so it misses libstdc++ headers installed under Debian/Ubuntu's include/<triplet>/c++/<version>/ layout. Testing the exact regex from this file confirms that /usr/include/c++/13/bits/stl_construct.h matches, while /usr/include/x86_64-linux-gnu/c++/13/bits/c++config.h does not. An embedded literal naming a header in that directory would therefore pass the new gate, despite being a standard-library header path. This does not undermine detection of the forwarding-wrapper regressions described in the PR, whose headers use the main include directory, so this is non-blocking. Allow intervening directory components while retaining the existing printable-byte restriction.

Suggested change
# Matches the include directory of both libc++ (include/c++/v1) and libstdc++
# (include/c++/<version>), wherever the toolchain or sysroot places it.
STDLIB_INCLUDE = re.compile(rb'[\x20-\x7e]*include/c\+\+/[\x20-\x7e]*')
# Matches the include directory of both libc++ (include/c++/v1) and libstdc++
# (include/c++/<version>), including multiarch layouts such as
# include/<triplet>/c++/<version>, wherever the toolchain or sysroot places it.
STDLIB_INCLUDE = re.compile(rb'[\x20-\x7e]*include/(?:[-\w.]+/)*c\+\+/[\x20-\x7e]*')

source: muse-spark-1.3-contributor (phase1-reviewer: general)


# Where a string literal ends up: .rodata and its variants on ELF, .rdata on
# PE, __cstring and __const on Mach-O.
LITERAL_SECTIONS = ('.rodata', '.rdata', '__cstring', '__const')

def section_label(section) -> str:
# Mach-O sections are only meaningful together with their segment.
segment = getattr(section, 'segment_name', '')
return f'{segment},{section.name}' if segment else section.name

def holds_string_literals(section) -> bool:
return section.name.startswith(LITERAL_SECTIONS)

def embedded_stdlib_paths(filename) -> list:
binary = lief.parse(filename)
if binary is None:
raise IOError(f'{filename}: unable to parse')
found: dict = {}
for section in binary.sections:
if not holds_string_literals(section):
continue
label = section_label(section)
content = bytes(section.content)
for match in STDLIB_INCLUDE.finditer(content):
path = match.group().decode(errors='replace')
# Identical literals are usually merged, so the count is a lower
# bound on the number of sites. Offsets let a hit be attributed to
# referencing code without rebuilding anything.
found.setdefault((label, path), []).append(
section.virtual_address + match.start())
return sorted((label, path, offsets)
for (label, path), offsets in found.items())

def main() -> None:
retval = 0
for filename in sys.argv[1:]:
try:
paths = embedded_stdlib_paths(filename)
except IOError as e:
print(f'{e}')
retval = 1
continue
for label, path, offsets in paths:
where = ' '.join(f'0x{o:x}' for o in offsets[:4])
if len(offsets) > 4:
where += ' ...'
print(f'{filename}: {label} embeds standard library header path '
f'{path} (x{len(offsets)} at {where})')
retval = 1
sys.exit(retval)

if __name__ == '__main__':
main()
6 changes: 5 additions & 1 deletion src/Makefile.am
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ if BUILD_BITCOIN_CHAINSTATE
bin_PROGRAMS += dash-chainstate
endif

.PHONY: FORCE check-symbols check-security
.PHONY: FORCE check-symbols check-security check-stdlib-paths
# dash core #
BITCOIN_CORE_H = \
active/context.h \
Expand Down Expand Up @@ -1495,6 +1495,10 @@ if HARDEN
$(AM_V_at) $(PYTHON) $(top_srcdir)/contrib/guix/security-check.py $(bin_PROGRAMS)
endif

check-stdlib-paths: $(bin_PROGRAMS)
@echo "Checking for embedded standard library header paths..."
$(AM_V_at) $(PYTHON) $(top_srcdir)/contrib/guix/stdlib-path-check.py $(bin_PROGRAMS)


osx_debug: $(bin_PROGRAMS)
for i in $(bin_PROGRAMS); do mkdir -p $$i.dSYM/Contents/Resources/DWARF && $(DSYMUTIL_FLAT) -o $$i.dSYM/Contents/Resources/DWARF/$$(basename $$i) $$i &> /dev/null ; done
Expand Down
Loading