| #!/usr/bin/env perl | |
| # SPDX-License-Identifier: GPL-2.0-only | |
| # | |
| # (c) 2017 Tobin C. Harding <me@tobin.cc> | |
| # | |
| # leaking_addresses.pl: Scan the kernel for potential leaking addresses. | |
| # - Scans dmesg output. | |
| # - Walks directory tree and parses each file (for each directory in @DIRS). | |
| # | |
| # Use --debug to output path before parsing, this is useful to find files that | |
| # cause the script to choke. | |
| # | |
| # When the system is idle it is likely that most files under /proc/PID will be | |
| # identical for various processes. Scanning _all_ the PIDs under /proc is | |
| # unnecessary and implies that we are thoroughly scanning /proc. This is _not_ | |
| # the case because there may be ways userspace can trigger creation of /proc | |
| # files that leak addresses but were not present during a scan. For these two | |
| # reasons we exclude all PID directories under /proc except '1/' | |
| use warnings; | |
| use strict; | |
| use POSIX; | |
| use File::Basename; | |
| use File::Spec; | |
| use Cwd 'abs_path'; | |
| use Term::ANSIColor qw(:constants); | |
| use Getopt::Long qw(:config no_auto_abbrev); | |
| use Config; | |
| use bigint qw/hex/; | |
| use feature 'state'; | |
| my $P = $0; | |
| # Directories to scan. | |
| my @DIRS = ('/proc', '/sys'); | |
| # Timer for parsing each file, in seconds. | |
| my $TIMEOUT = 10; | |
| # Kernel addresses vary by architecture. We can only auto-detect the following | |
| # architectures (using `uname -m`). (flag --32-bit overrides auto-detection.) | |
| my @SUPPORTED_ARCHITECTURES = ('x86_64', 'ppc64', 'x86'); | |
| # Command line options. | |
| my $help = 0; | |
| my $debug = 0; | |
| my $raw = 0; | |
| my $output_raw = ""; # Write raw results to file. | |
| my $input_raw = ""; # Read raw results from file instead of scanning. | |
| my $suppress_dmesg = 0; # Don't show dmesg in output. | |
| my $squash_by_path = 0; # Summary report grouped by absolute path. | |
| my $squash_by_filename = 0; # Summary report grouped by filename. | |
| my $kernel_config_file = ""; # Kernel configuration file. | |
| my $opt_32bit = 0; # Scan 32-bit kernel. | |
| my $page_offset_32bit = 0; # Page offset for 32-bit kernel. | |
| # Skip these absolute paths. | |
| my @skip_abs = ( | |
| '/proc/kmsg', | |
| '/proc/device-tree', | |
| '/proc/1/syscall', | |
| '/sys/firmware/devicetree', | |
| '/sys/kernel/debug/tracing/trace_pipe', | |
| '/sys/kernel/security/apparmor/revision'); | |
| # Skip these under any subdirectory. | |
| my @skip_any = ( | |
| 'pagemap', | |
| 'events', | |
| 'access', | |
| 'registers', | |
| 'snapshot_raw', | |
| 'trace_pipe_raw', | |
| 'ptmx', | |
| 'trace_pipe', | |
| 'fd', | |
| 'usbmon'); | |
| sub help | |
| { | |
| my ($exitcode) = @_; | |
| print << "EOM"; | |
| Usage: $P [OPTIONS] | |
| Options: | |
| -o, --output-raw=<file> Save results for future processing. | |
| -i, --input-raw=<file> Read results from file instead of scanning. | |
| --raw Show raw results (default). | |
| --suppress-dmesg Do not show dmesg results. | |
| --squash-by-path Show one result per unique path. | |
| --squash-by-filename Show one result per unique filename. | |
| --kernel-config-file=<file> Kernel configuration file (e.g /boot/config) | |
| --32-bit Scan 32-bit kernel. | |
| --page-offset-32-bit=o Page offset (for 32-bit kernel 0xABCD1234). | |
| -d, --debug Display debugging output. | |
| -h, --help Display this help and exit. | |
| Scans the running kernel for potential leaking addresses. | |
| EOM | |
| exit($exitcode); | |
| } | |
| GetOptions( | |
| 'd|debug' => \$debug, | |
| 'h|help' => \$help, | |
| 'o|output-raw=s' => \$output_raw, | |
| 'i|input-raw=s' => \$input_raw, | |
| 'suppress-dmesg' => \$suppress_dmesg, | |
| 'squash-by-path' => \$squash_by_path, | |
| 'squash-by-filename' => \$squash_by_filename, | |
| 'raw' => \$raw, | |
| 'kernel-config-file=s' => \$kernel_config_file, | |
| '32-bit' => \$opt_32bit, | |
| 'page-offset-32-bit=o' => \$page_offset_32bit, | |
| ) or help(1); | |
| help(0) if ($help); | |
| if ($input_raw) { | |
| format_output($input_raw); | |
| exit(0); | |
| } | |
| if (!$input_raw and ($squash_by_path or $squash_by_filename)) { | |
| printf "\nSummary reporting only available with --input-raw=<file>\n"; | |
| printf "(First run scan with --output-raw=<file>.)\n"; | |
| exit(128); | |
| } | |
| if (!(is_supported_architecture() or $opt_32bit or $page_offset_32bit)) { | |
| printf "\nScript does not support your architecture, sorry.\n"; | |
| printf "\nCurrently we support: \n\n"; | |
| foreach(@SUPPORTED_ARCHITECTURES) { | |
| printf "\t%s\n", $_; | |
| } | |
| printf("\n"); | |
| printf("If you are running a 32-bit architecture you may use:\n"); | |
| printf("\n\t--32-bit or --page-offset-32-bit=<page offset>\n\n"); | |
| my $archname = `uname -m`; | |
| printf("Machine hardware name (`uname -m`): %s\n", $archname); | |
| exit(129); | |
| } | |
| if ($output_raw) { | |
| open my $fh, '>', $output_raw or die "$0: $output_raw: $!\n"; | |
| select $fh; | |
| } | |
| parse_dmesg(); | |
| walk(@DIRS); | |
| exit 0; | |
| sub dprint | |
| { | |
| printf(STDERR @_) if $debug; | |
| } | |
| sub is_supported_architecture | |
| { | |
| return (is_x86_64() or is_ppc64() or is_ix86_32()); | |
| } | |
| sub is_32bit | |
| { | |
| # Allow --32-bit or --page-offset-32-bit to override | |
| if ($opt_32bit or $page_offset_32bit) { | |
| return 1; | |
| } | |
| return is_ix86_32(); | |
| } | |
| sub is_ix86_32 | |
| { | |
| state $arch = `uname -m`; | |
| chomp $arch; | |
| if ($arch =~ m/i[3456]86/) { | |
| return 1; | |
| } | |
| return 0; | |
| } | |
| sub is_arch | |
| { | |
| my ($desc) = @_; | |
| my $arch = `uname -m`; | |
| chomp $arch; | |
| if ($arch eq $desc) { | |
| return 1; | |
| } | |
| return 0; | |
| } | |
| sub is_x86_64 | |
| { | |
| state $is = is_arch('x86_64'); | |
| return $is; | |
| } | |
| sub is_ppc64 | |
| { | |
| state $is = is_arch('ppc64'); | |
| return $is; | |
| } | |
| # Gets config option value from kernel config file. | |
| # Returns "" on error or if config option not found. | |
| sub get_kernel_config_option | |
| { | |
| my ($option) = @_; | |
| my $value = ""; | |
| my $tmp_file = ""; | |
| my @config_files; | |
| # Allow --kernel-config-file to override. | |
| if ($kernel_config_file ne "") { | |
| @config_files = ($kernel_config_file); | |
| } elsif (-R "/proc/config.gz") { | |
| my $tmp_file = "/tmp/tmpkconf"; | |
| if (system("gunzip < /proc/config.gz > $tmp_file")) { | |
| dprint("system(gunzip < /proc/config.gz) failed\n"); | |
| return ""; | |
| } else { | |
| @config_files = ($tmp_file); | |
| } | |
| } else { | |
| my $file = '/boot/config-' . `uname -r`; | |
| chomp $file; | |
| @config_files = ($file, '/boot/config'); | |
| } | |
| foreach my $file (@config_files) { | |
| dprint("parsing config file: $file\n"); | |
| $value = option_from_file($option, $file); | |
| if ($value ne "") { | |
| last; | |
| } | |
| } | |
| if ($tmp_file ne "") { | |
| system("rm -f $tmp_file"); | |
| } | |
| return $value; | |
| } | |
| # Parses $file and returns kernel configuration option value. | |
| sub option_from_file | |
| { | |
| my ($option, $file) = @_; | |
| my $str = ""; | |
| my $val = ""; | |
| open(my $fh, "<", $file) or return ""; | |
| while (my $line = <$fh> ) { | |
| if ($line =~ /^$option/) { | |
| ($str, $val) = split /=/, $line; | |
| chomp $val; | |
| last; | |
| } | |
| } | |
| close $fh; | |
| return $val; | |
| } | |
| sub is_false_positive | |
| { | |
| my ($match) = @_; | |
| if (is_32bit()) { | |
| return is_false_positive_32bit($match); | |
| } | |
| # 64 bit false positives. | |
| if ($match =~ '\b(0x)?(f|F){16}\b' or | |
| $match =~ '\b(0x)?0{16}\b') { | |
| return 1; | |
| } | |
| if (is_x86_64() and is_in_vsyscall_memory_region($match)) { | |
| return 1; | |
| } | |
| return 0; | |
| } | |
| sub is_false_positive_32bit | |
| { | |
| my ($match) = @_; | |
| state $page_offset = get_page_offset(); | |
| if ($match =~ '\b(0x)?(f|F){8}\b') { | |
| return 1; | |
| } | |
| if (hex($match) < $page_offset) { | |
| return 1; | |
| } | |
| return 0; | |
| } | |
| # returns integer value | |
| sub get_page_offset | |
| { | |
| my $page_offset; | |
| my $default_offset = 0xc0000000; | |
| # Allow --page-offset-32bit to override. | |
| if ($page_offset_32bit != 0) { | |
| return $page_offset_32bit; | |
| } | |
| $page_offset = get_kernel_config_option('CONFIG_PAGE_OFFSET'); | |
| if (!$page_offset) { | |
| return $default_offset; | |
| } | |
| return $page_offset; | |
| } | |
| sub is_in_vsyscall_memory_region | |
| { | |
| my ($match) = @_; | |
| my $hex = hex($match); | |
| my $region_min = hex("0xffffffffff600000"); | |
| my $region_max = hex("0xffffffffff601000"); | |
| return ($hex >= $region_min and $hex <= $region_max); | |
| } | |
| # True if argument potentially contains a kernel address. | |
| sub may_leak_address | |
| { | |
| my ($line) = @_; | |
| my $address_re; | |
| # Signal masks. | |
| if ($line =~ '^SigBlk:' or | |
| $line =~ '^SigIgn:' or | |
| $line =~ '^SigCgt:') { | |
| return 0; | |
| } | |
| if ($line =~ '\bKEY=[[:xdigit:]]{14} [[:xdigit:]]{16} [[:xdigit:]]{16}\b' or | |
| $line =~ '\b[[:xdigit:]]{14} [[:xdigit:]]{16} [[:xdigit:]]{16}\b') { | |
| return 0; | |
| } | |
| $address_re = get_address_re(); | |
| while ($line =~ /($address_re)/g) { | |
| if (!is_false_positive($1)) { | |
| return 1; | |
| } | |
| } | |
| return 0; | |
| } | |
| sub get_address_re | |
| { | |
| if (is_ppc64()) { | |
| return '\b(0x)?[89abcdef]00[[:xdigit:]]{13}\b'; | |
| } elsif (is_32bit()) { | |
| return '\b(0x)?[[:xdigit:]]{8}\b'; | |
| } | |
| return get_x86_64_re(); | |
| } | |
| sub get_x86_64_re | |
| { | |
| # We handle page table levels but only if explicitly configured using | |
| # CONFIG_PGTABLE_LEVELS. If config file parsing fails or config option | |
| # is not found we default to using address regular expression suitable | |
| # for 4 page table levels. | |
| state $ptl = get_kernel_config_option('CONFIG_PGTABLE_LEVELS'); | |
| if ($ptl == 5) { | |
| return '\b(0x)?ff[[:xdigit:]]{14}\b'; | |
| } | |
| return '\b(0x)?ffff[[:xdigit:]]{12}\b'; | |
| } | |
| sub parse_dmesg | |
| { | |
| open my $cmd, '-|', 'dmesg'; | |
| while (<$cmd>) { | |
| if (may_leak_address($_)) { | |
| print 'dmesg: ' . $_; | |
| } | |
| } | |
| close $cmd; | |
| } | |
| # True if we should skip this path. | |
| sub skip | |
| { | |
| my ($path) = @_; | |
| foreach (@skip_abs) { | |
| return 1 if (/^$path$/); | |
| } | |
| my($filename, $dirs, $suffix) = fileparse($path); | |
| foreach (@skip_any) { | |
| return 1 if (/^$filename$/); | |
| } | |
| return 0; | |
| } | |
| sub timed_parse_file | |
| { | |
| my ($file) = @_; | |
| eval { | |
| local $SIG{ALRM} = sub { die "alarm\n" }; # NB: \n required. | |
| alarm $TIMEOUT; | |
| parse_file($file); | |
| alarm 0; | |
| }; | |
| if ($@) { | |
| die unless $@ eq "alarm\n"; # Propagate unexpected errors. | |
| printf STDERR "timed out parsing: %s\n", $file; | |
| } | |
| } | |
| sub parse_file | |
| { | |
| my ($file) = @_; | |
| if (! -R $file) { | |
| return; | |
| } | |
| if (! -T $file) { | |
| return; | |
| } | |
| open my $fh, "<", $file or return; | |
| while ( <$fh> ) { | |
| if (may_leak_address($_)) { | |
| print $file . ': ' . $_; | |
| } | |
| } | |
| close $fh; | |
| } | |
| # Checks if the actual path name is leaking a kernel address. | |
| sub check_path_for_leaks | |
| { | |
| my ($path) = @_; | |
| if (may_leak_address($path)) { | |
| printf("Path name may contain address: $path\n"); | |
| } | |
| } | |
| # Recursively walk directory tree. | |
| sub walk | |
| { | |
| my @dirs = @_; | |
| while (my $pwd = shift @dirs) { | |
| next if (!opendir(DIR, $pwd)); | |
| my @files = readdir(DIR); | |
| closedir(DIR); | |
| foreach my $file (@files) { | |
| next if ($file eq '.' or $file eq '..'); | |
| my $path = "$pwd/$file"; | |
| next if (-l $path); | |
| # skip /proc/PID except /proc/1 | |
| next if (($path =~ /^\/proc\/[0-9]+$/) && | |
| ($path !~ /^\/proc\/1$/)); | |
| next if (skip($path)); | |
| check_path_for_leaks($path); | |
| if (-d $path) { | |
| push @dirs, $path; | |
| next; | |
| } | |
| dprint("parsing: $path\n"); | |
| timed_parse_file($path); | |
| } | |
| } | |
| } | |
| sub format_output | |
| { | |
| my ($file) = @_; | |
| # Default is to show raw results. | |
| if ($raw or (!$squash_by_path and !$squash_by_filename)) { | |
| dump_raw_output($file); | |
| return; | |
| } | |
| my ($total, $dmesg, $paths, $files) = parse_raw_file($file); | |
| printf "\nTotal number of results from scan (incl dmesg): %d\n", $total; | |
| if (!$suppress_dmesg) { | |
| print_dmesg($dmesg); | |
| } | |
| if ($squash_by_filename) { | |
| squash_by($files, 'filename'); | |
| } | |
| if ($squash_by_path) { | |
| squash_by($paths, 'path'); | |
| } | |
| } | |
| sub dump_raw_output | |
| { | |
| my ($file) = @_; | |
| open (my $fh, '<', $file) or die "$0: $file: $!\n"; | |
| while (<$fh>) { | |
| if ($suppress_dmesg) { | |
| if ("dmesg:" eq substr($_, 0, 6)) { | |
| next; | |
| } | |
| } | |
| print $_; | |
| } | |
| close $fh; | |
| } | |
| sub parse_raw_file | |
| { | |
| my ($file) = @_; | |
| my $total = 0; # Total number of lines parsed. | |
| my @dmesg; # dmesg output. | |
| my %files; # Unique filenames containing leaks. | |
| my %paths; # Unique paths containing leaks. | |
| open (my $fh, '<', $file) or die "$0: $file: $!\n"; | |
| while (my $line = <$fh>) { | |
| $total++; | |
| if ("dmesg:" eq substr($line, 0, 6)) { | |
| push @dmesg, $line; | |
| next; | |
| } | |
| cache_path(\%paths, $line); | |
| cache_filename(\%files, $line); | |
| } | |
| return $total, \@dmesg, \%paths, \%files; | |
| } | |
| sub print_dmesg | |
| { | |
| my ($dmesg) = @_; | |
| print "\ndmesg output:\n"; | |
| if (@$dmesg == 0) { | |
| print "<no results>\n"; | |
| return; | |
| } | |
| foreach(@$dmesg) { | |
| my $index = index($_, ': '); | |
| $index += 2; # skid ': ' | |
| print substr($_, $index); | |
| } | |
| } | |
| sub squash_by | |
| { | |
| my ($ref, $desc) = @_; | |
| print "\nResults squashed by $desc (excl dmesg). "; | |
| print "Displaying [<number of results> <$desc>], <example result>\n"; | |
| if (keys %$ref == 0) { | |
| print "<no results>\n"; | |
| return; | |
| } | |
| foreach(keys %$ref) { | |
| my $lines = $ref->{$_}; | |
| my $length = @$lines; | |
| printf "[%d %s] %s", $length, $_, @$lines[0]; | |
| } | |
| } | |
| sub cache_path | |
| { | |
| my ($paths, $line) = @_; | |
| my $index = index($line, ': '); | |
| my $path = substr($line, 0, $index); | |
| $index += 2; # skip ': ' | |
| add_to_cache($paths, $path, substr($line, $index)); | |
| } | |
| sub cache_filename | |
| { | |
| my ($files, $line) = @_; | |
| my $index = index($line, ': '); | |
| my $path = substr($line, 0, $index); | |
| my $filename = basename($path); | |
| $index += 2; # skip ': ' | |
| add_to_cache($files, $filename, substr($line, $index)); | |
| } | |
| sub add_to_cache | |
| { | |
| my ($cache, $key, $value) = @_; | |
| if (!$cache->{$key}) { | |
| $cache->{$key} = (); | |
| } | |
| push @{$cache->{$key}}, $value; | |
| } |