Skip to content

Commit a7b1d8e

Browse files
committed
symcheck: Check for binaries requiring a writeable + executable stack
Implement the following logic: * For elf executable binaries, check for PT_GNU_STACK with PF_X. This combination tells the kernel to make the stack executable. * For elf intermediate objects, check for `.note.GNU-stack` with SHF_EXECINSTR. This combination in an object file tells the linker to give the final binary PT_GNU_STACK PF_X. * For elf intermediate objects with no `.note.GNU-stack`, assume the legacy behavior that assumes an executable stack is required. * For non-elf binaries, don't check anything. In a follow up it may be possible to check for `MH_ALLOW_STACK_EXECUTION` on Mach-O binaries, but it doesn't seem possible to get the latest compiler to emit this. This appears to match what is done by `scanelf` to emit `!WX` [1], which seems to be the tool used to create the output in the issue. The ld manpage [2] also has some useful notes about these flags, as does the presentation at [3]. Closes: #183 [1]: https://github.com/gentoo/pax-utils/blob/9ef54b472e42ba2c5479fbd86b8be2275724b064/scanelf.c [2]: https://man7.org/linux/man-pages/man1/ld.1.html [3]: https://www.ndss-symposium.org/wp-content/uploads/6D-s0924-ye.pdf
1 parent 2db897f commit a7b1d8e

File tree

7 files changed

+406
-8
lines changed

7 files changed

+406
-8
lines changed

ci/run.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ SYMCHECK_TEST_TARGET="$target" cargo test -p symbol-check --release
5050
symcheck=(cargo run -p symbol-check --release)
5151
symcheck+=(-- --build-and-check --target "$target")
5252

53+
# Executable section checks are meaningless on no-std targets
54+
[[ "$target" == *"-none"* ]] && symcheck+=(--no-os)
55+
5356
"${symcheck[@]}" -- -p compiler_builtins
5457
"${symcheck[@]}" -- -p compiler_builtins --release
5558
"${symcheck[@]}" -- -p compiler_builtins --features c

crates/symbol-check/src/main.rs

Lines changed: 187 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,27 @@
55
//! actual target is cross compiled.
66
77
use std::collections::{BTreeMap, BTreeSet, HashSet};
8-
use std::fs;
98
use std::io::{BufRead, BufReader};
109
use std::path::{Path, PathBuf};
1110
use std::process::{Command, Stdio, exit};
1211
use std::sync::LazyLock;
12+
use std::{env, fs};
1313

1414
use object::read::archive::ArchiveFile;
1515
use object::{
16-
BinaryFormat, File as ObjFile, Object, ObjectSection, ObjectSymbol, Result as ObjResult,
17-
Symbol, SymbolKind, SymbolScope,
16+
Architecture, BinaryFormat, Endianness, File as ObjFile, Object, ObjectSection, ObjectSymbol,
17+
Result as ObjResult, SectionFlags, Symbol, SymbolKind, SymbolScope, U32, elf,
1818
};
1919
use regex::Regex;
2020
use serde_json::Value;
2121

2222
const CHECK_LIBRARIES: &[&str] = &["compiler_builtins", "builtins_test_intrinsics"];
2323
const CHECK_EXTENSIONS: &[Option<&str>] = &[Some("rlib"), Some("a"), Some("exe"), None];
24+
const GNU_STACK: &str = ".note.GNU-stack";
2425

2526
const USAGE: &str = "Usage:
2627
27-
symbol-check --build-and-check [--target TARGET] -- CARGO_BUILD_ARGS ...
28+
symbol-check --build-and-check [--target TARGET] [--no-os] -- CARGO_BUILD_ARGS ...
2829
symbol-check --check PATHS ...\
2930
";
3031

@@ -51,6 +52,12 @@ fn main() {
5152
"Run checks on the given set of paths, without invoking Cargo. Paths \
5253
may be either archives or object files.",
5354
);
55+
opts.optflag(
56+
"",
57+
"no-os",
58+
"The binaries will not be checked for executable stacks. Used for embedded targets which \
59+
don't set `.note.GNU-stack` since there is no protection.",
60+
);
5461

5562
let print_usage_and_exit = |code: i32| -> ! {
5663
eprintln!("{}", opts.usage(USAGE));
@@ -66,6 +73,7 @@ fn main() {
6673
print_usage_and_exit(0);
6774
}
6875

76+
let no_os_target = m.opt_present("no-os");
6977
let free_args = m.free.iter().map(String::as_str).collect::<Vec<_>>();
7078
for arg in &free_args {
7179
assert!(
@@ -77,25 +85,26 @@ fn main() {
7785
if m.opt_present("build-and-check") {
7886
let target = m.opt_str("target").unwrap_or(env!("HOST").to_string());
7987
let paths = exec_cargo_with_args(&target, &free_args);
80-
check_paths(&paths);
88+
check_paths(&paths, no_os_target);
8189
} else if m.opt_present("check") {
8290
if free_args.is_empty() {
8391
print_usage_and_exit(1);
8492
}
85-
check_paths(&free_args);
93+
check_paths(&free_args, no_os_target);
8694
} else {
8795
print_usage_and_exit(1);
8896
}
8997
}
9098

91-
fn check_paths<P: AsRef<Path>>(paths: &[P]) {
99+
fn check_paths<P: AsRef<Path>>(paths: &[P], no_os_target: bool) {
92100
for path in paths {
93101
let path = path.as_ref();
94102
println!("Checking {}", path.display());
95103
let archive = BinFile::from_path(path);
96104

97105
verify_no_duplicates(&archive);
98106
verify_core_symbols(&archive);
107+
verify_no_exec_stack(&archive, no_os_target);
99108
}
100109
}
101110

@@ -320,6 +329,177 @@ fn verify_core_symbols(archive: &BinFile) {
320329
println!(" success: no undefined references to core found");
321330
}
322331

332+
/// Reasons a binary is considered to have an executable stack.
333+
enum ExeStack {
334+
MissingGnuStackSec,
335+
ExeGnuStackSec,
336+
ExePtGnuStack,
337+
}
338+
339+
/// Ensure that the object/archive will not require an executable stack.
340+
fn verify_no_exec_stack(archive: &BinFile, no_os_target: bool) {
341+
if no_os_target {
342+
// We don't really have a good way of knowing whether or not an elf file is for a
343+
// no-os environment so we rely on a CLI arg (note.GNU-stack doesn't get emitted if
344+
// there is no OS to protect the stack).
345+
println!(" skipping check for writeable+executable stack on no-os target");
346+
return;
347+
}
348+
349+
let mut problem_objfiles = Vec::new();
350+
351+
archive.for_each_object(|obj, obj_path| match check_obj_exe_stack(&obj) {
352+
Ok(()) => (),
353+
Err(exe) => problem_objfiles.push((obj_path.to_owned(), exe)),
354+
});
355+
356+
if problem_objfiles.is_empty() {
357+
println!(" success: no writeable+executable stack indicators found");
358+
return;
359+
}
360+
361+
eprintln!("the following object files require an executable stack:");
362+
363+
for (obj, exe) in problem_objfiles {
364+
let reason = match exe {
365+
ExeStack::MissingGnuStackSec => "no .note.GNU-stack section",
366+
ExeStack::ExeGnuStackSec => ".note.GNU-stack section marked SHF_EXECINSTR",
367+
ExeStack::ExePtGnuStack => "PT_GNU_STACK program header marked PF_X",
368+
};
369+
eprintln!(" {obj} ({reason})");
370+
}
371+
372+
exit(1);
373+
}
374+
375+
/// `Err` if the section/flag combination indicates that the object file should be linked with an
376+
/// executable stack.
377+
fn check_obj_exe_stack(obj: &ObjFile) -> Result<(), ExeStack> {
378+
match obj.format() {
379+
BinaryFormat::Elf => check_elf_exe_stack(obj),
380+
// Technically has the `MH_ALLOW_STACK_EXECUTION` flag but I can't get the compiler to
381+
// emit it (`-allow_stack_execute` doesn't seem to work in recent versions).
382+
BinaryFormat::MachO => Ok(()),
383+
// Can't find much information about Windows stack executability.
384+
BinaryFormat::Coff | BinaryFormat::Pe => Ok(()),
385+
// Also not sure about wasm.
386+
BinaryFormat::Wasm => Ok(()),
387+
BinaryFormat::Xcoff | _ => {
388+
unimplemented!("binary format {:?} is not supported", obj.format())
389+
}
390+
}
391+
}
392+
393+
/// Check for an executable stack in elf binaries.
394+
///
395+
/// If the `PT_GNU_STACK` header on a binary is present and marked executable, the binary will
396+
/// have an executable stack (RWE rather than the desired RW). If any object file has the right
397+
/// `.note.GNU-stack` logic, the final binary will get `PT_GNU_STACK`.
398+
///
399+
/// Individual object file logic is as follows, paraphrased from [1]:
400+
///
401+
/// - A `.note.GNU-stack` section with the exe flag means this needs an executable stack
402+
/// - A `.note.GNU-stack` section without the exe flag means there is no executable stack needed
403+
/// - Without the section, behavior is target-specific. Historically it usually means an executable
404+
/// stack is required.
405+
///
406+
/// Per [2], it is now deprecated behavior for a missing `.note.GNU-stack` section to imply an
407+
/// executable stack. However, we shouldn't assume that tooling has caught up to this.
408+
///
409+
/// [1]: https://www.man7.org/linux/man-pages/man1/ld.1.html
410+
/// [2]: https://sourceware.org/git/gitweb.cgi?p=binutils-gdb.git;h=0d38576a34ec64a1b4500c9277a8e9d0f07e6774>
411+
fn check_elf_exe_stack(obj: &ObjFile) -> Result<(), ExeStack> {
412+
let end = obj.endianness();
413+
414+
// Check for PT_GNU_STACK marked executable
415+
let mut is_obj_exe = false;
416+
let mut found_gnu_stack = false;
417+
let mut check_ph = |p_type: U32<Endianness>, p_flags: U32<Endianness>| {
418+
let ty = p_type.get(end);
419+
let flags = p_flags.get(end);
420+
421+
// Presence of PT_INTERP indicates that this is an executable rather than a standalone
422+
// object file.
423+
if ty == elf::PT_INTERP {
424+
is_obj_exe = true;
425+
}
426+
427+
if ty == elf::PT_GNU_STACK {
428+
assert!(!found_gnu_stack, "multiple PT_GNU_STACK sections");
429+
found_gnu_stack = true;
430+
if flags & elf::PF_X != 0 {
431+
return Err(ExeStack::ExePtGnuStack);
432+
}
433+
}
434+
435+
Ok(())
436+
};
437+
438+
match obj {
439+
ObjFile::Elf32(f) => {
440+
for ph in f.elf_program_headers() {
441+
check_ph(ph.p_type, ph.p_flags)?;
442+
}
443+
}
444+
ObjFile::Elf64(f) => {
445+
for ph in f.elf_program_headers() {
446+
check_ph(ph.p_type, ph.p_flags)?;
447+
}
448+
}
449+
_ => panic!("should only be called with elf objects"),
450+
}
451+
452+
if is_obj_exe {
453+
return Ok(());
454+
}
455+
456+
// The remaining are checks for individual object files, which wind up controlling PT_GNU_STACK
457+
// in the final binary.
458+
let mut gnu_stack_exe = None;
459+
let mut has_exe_sections = false;
460+
for sec in obj.sections() {
461+
let SectionFlags::Elf { sh_flags } = sec.flags() else {
462+
unreachable!("only elf files are being checked");
463+
};
464+
465+
let is_sec_exe = sh_flags & u64::from(elf::SHF_EXECINSTR) != 0;
466+
467+
// If the magic section is present, its exe bit tells us whether or not the object
468+
// file requires an executable stack.
469+
if sec.name().unwrap_or_default() == GNU_STACK {
470+
assert!(gnu_stack_exe.is_none(), "multiple {GNU_STACK} sections");
471+
if is_sec_exe {
472+
gnu_stack_exe = Some(Err(ExeStack::ExeGnuStackSec));
473+
} else {
474+
gnu_stack_exe = Some(Ok(()));
475+
}
476+
}
477+
478+
// Otherwise, just keep track of whether or not we have exeuctable sections
479+
has_exe_sections |= is_sec_exe;
480+
}
481+
482+
// GNU_STACK sets the executability if specified.
483+
if let Some(exe) = gnu_stack_exe {
484+
return exe;
485+
}
486+
487+
// Ignore object files that have no executable sections, like rmeta.
488+
if !has_exe_sections {
489+
return Ok(());
490+
}
491+
492+
// If there is no `.note.GNU-stack` and no executable sections, behavior differs by platform.
493+
match obj.architecture() {
494+
// PPC64 doesn't set `.note.GNU-stack` since GNU nested functions don't need a trampoline,
495+
// <https://gcc.gnu.org/bugzilla/show_bug.cgi?id=21098>. From experimentation, it seems
496+
// like this only applies to big endian.
497+
Architecture::PowerPc64 if obj.endianness() == Endianness::Big => Ok(()),
498+
499+
_ => Err(ExeStack::MissingGnuStackSec),
500+
}
501+
}
502+
323503
/// Thin wrapper for owning data used by `object`.
324504
struct BinFile {
325505
path: PathBuf,

0 commit comments

Comments
 (0)