Skip to content

Commit fa0c0bd

Browse files
authored
Merge pull request #556 from ReFirmLabs/unpriv_user_exec
Symlink directory traversal security fix
2 parents 0499019 + 8f3dd37 commit fa0c0bd

File tree

6 files changed

+173
-39
lines changed

6 files changed

+173
-39
lines changed

README.md

+7
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,17 @@
77

88
Binwalk is a fast, easy to use tool for analyzing, reverse engineering, and extracting firmware images.
99

10+
11+
### *** Extraction Security Notice ***
12+
13+
Prior to Binwalk v2.3.3, extracted archives could create symlinks which point anywhere on the file system, potentially resulting in a directory traversal attack if subsequent extraction utilties blindly follow these symlinks. More generically, Binwalk makes use of many third-party extraction utilties which may have unpatched security issues; Binwalk v2.3.3 and later allows external extraction tools to be run as an unprivileged user using the `run-as` command line option (this requires Binwalk itself to be run with root privileges). Additionally, Binwalk v2.3.3 and later will refuse to perform extraction as root unless `--run-as=root` is specified.
14+
15+
1016
### *** Python 2.7 Deprecation Notice ***
1117

1218
Even though many major Linux distros are still shipping Python 2.7 as the default interpreter in their currently stable release, we are making the difficult decision to move binwalk support exclusively to Python 3. This is likely to make many upset and others rejoice. If you need to install binwalk into a Python 2.7 environment we will be creating a tag `python27` that will be a snapshot of `master` before all of these major changes are made. Thank you for being patient with us through this transition process.
1319

20+
1421
### Installation and Usage
1522

1623
* [Installation](./INSTALL.md)

setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from distutils.dir_util import remove_tree
1313

1414
MODULE_NAME = "binwalk"
15-
MODULE_VERSION = "2.3.2"
15+
MODULE_VERSION = "2.3.3"
1616
SCRIPT_NAME = MODULE_NAME
1717
MODULE_DIRECTORY = os.path.dirname(os.path.realpath(__file__))
1818

src/binwalk/modules/extractor.py

+132-36
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44

55
import os
66
import re
7+
import pwd
78
import stat
89
import shlex
910
import tempfile
1011
import subprocess
1112
import binwalk.core.common
1213
from binwalk.core.compat import *
14+
from binwalk.core.exceptions import ModuleException
1315
from binwalk.core.module import Module, Option, Kwarg
1416
from binwalk.core.common import file_size, file_md5, unique_file_name, BlockFile
1517

@@ -87,11 +89,20 @@ class Extractor(Module):
8789
type=int,
8890
kwargs={'max_count': 0},
8991
description='Limit the number of extracted files'),
92+
Option(short='0',
93+
long='run-as',
94+
type=str,
95+
kwargs={'runas_user': 0},
96+
description="Execute external extraction utilities with the specified user's privileges"),
9097
#Option(short='u',
9198
# long='limit',
9299
# type=int,
93100
# kwargs={'recursive_max_size': 0},
94101
# description="Limit the total size of all extracted files"),
102+
Option(short='1',
103+
long='preserve-symlinks',
104+
kwargs={'do_not_sanitize_symlinks': True},
105+
description="Do not sanitize extracted symlinks that point outside the extraction directory (dangerous)"),
95106
Option(short='r',
96107
long='rm',
97108
kwargs={'remove_after_execute': True},
@@ -111,16 +122,43 @@ class Extractor(Module):
111122
Kwarg(name='recursive_max_size', default=None),
112123
Kwarg(name='max_count', default=None),
113124
Kwarg(name='base_directory', default=None),
125+
Kwarg(name='do_not_sanitize_symlinks', default=False),
114126
Kwarg(name='remove_after_execute', default=False),
115127
Kwarg(name='load_default_rules', default=False),
116128
Kwarg(name='run_extractors', default=True),
117129
Kwarg(name='extract_into_subdirs', default=False),
118130
Kwarg(name='manual_rules', default=[]),
119131
Kwarg(name='matryoshka', default=0),
120132
Kwarg(name='enabled', default=False),
133+
Kwarg(name='runas_user', default=None),
121134
]
122135

123136
def load(self):
137+
self.runas_uid = None
138+
self.runas_gid = None
139+
140+
if self.enabled is True:
141+
if self.runas_user is None:
142+
# Get some info about the current user we're running under
143+
user_info = pwd.getpwuid(os.getuid())
144+
145+
# Don't run as root, unless explicitly instructed to
146+
if user_info.pw_uid == 0:
147+
raise ModuleException("Binwalk extraction uses many third party utilities, which may not be secure. If you wish to have extraction utilities executed as the current user, use '--run-as=%s' (binwalk itself must be run as root)." % user_info.pw_name)
148+
149+
# Run external applications as the current user
150+
self.runas_uid = user_info.pw_uid
151+
self.runas_gid = user_info.pw_gid
152+
else:
153+
# Run external applications as the specified user
154+
user_info = pwd.getpwnam(self.runas_user)
155+
self.runas_uid = user_info.pw_uid
156+
self.runas_gid = user_info.pw_gid
157+
158+
# Make sure we'll have permissions to switch to the different user
159+
if self.runas_uid != os.getuid() and os.getuid() != 0:
160+
raise ModuleException("In order to execute third party applications as %s, binwalk must be run with root privileges." % self.runas_user)
161+
124162
# Holds a list of extraction rules loaded either from a file or when
125163
# manually specified.
126164
self.extract_rules = []
@@ -148,8 +186,8 @@ def load(self):
148186
self.config.verbose = True
149187

150188
def add_pending(self, f):
151-
# Ignore symlinks
152-
if os.path.islink(f):
189+
# Ignore symlinks, don't add new files unless recursion was requested
190+
if os.path.islink(f) or not self.matryoshka:
153191
return
154192

155193
# Get the file mode to check and see if it's a block/char device
@@ -260,30 +298,34 @@ def callback(self, r):
260298

261299
# If recursion was specified, and the file is not the same
262300
# one we just dd'd
263-
if (self.matryoshka and
264-
file_path != dd_file_path and
265-
scan_extracted_files and
266-
self.directory in real_file_path):
267-
# If the recursion level of this file is less than or
268-
# equal to our desired recursion level
269-
if len(real_file_path.split(self.directory)[1].split(os.path.sep)) <= self.matryoshka:
270-
# If this is a directory and we are supposed to process directories for this extractor,
271-
# then add all files under that directory to the
272-
# list of pending files.
273-
if os.path.isdir(file_path):
274-
for root, dirs, files in os.walk(file_path):
275-
for f in files:
276-
full_path = os.path.join(root, f)
277-
self.add_pending(full_path)
278-
# If it's just a file, it to the list of pending
279-
# files
280-
else:
281-
self.add_pending(file_path)
301+
if file_path != dd_file_path:
302+
# Symlinks can cause security issues if they point outside the extraction directory.
303+
self.symlink_sanitizer(file_path, extraction_directory)
304+
305+
# If this is a directory and we are supposed to process directories for this extractor,
306+
# then add all files under that directory to the
307+
# list of pending files.
308+
if os.path.isdir(file_path):
309+
for root, dirs, files in os.walk(file_path):
310+
# Symlinks can cause security issues if they point outside the extraction directory.
311+
self.symlink_sanitizer([os.path.join(root, x) for x in dirs+files], extraction_directory)
312+
313+
for f in files:
314+
full_path = os.path.join(root, f)
315+
316+
# If the recursion level of this file is less than or equal to our desired recursion level
317+
if len(real_file_path.split(self.directory)[1].split(os.path.sep)) <= self.matryoshka:
318+
if scan_extracted_files and self.directory in real_file_path:
319+
self.add_pending(full_path)
320+
321+
# If it's just a file, it to the list of pending
322+
# files
323+
elif scan_extracted_files and self.directory in real_file_path:
324+
self.add_pending(file_path)
282325

283326
# Update the last directory listing for the next time we
284327
# extract a file to this same output directory
285-
self.last_directory_listing[
286-
extraction_directory] = directory_listing
328+
self.last_directory_listing[extraction_directory] = directory_listing
287329

288330
def append_rule(self, r):
289331
self.extract_rules.append(r.copy())
@@ -534,6 +576,9 @@ def build_output_directory(self, path):
534576
else:
535577
output_directory = self.extraction_directories[path]
536578

579+
# Make sure run-as user can access this directory
580+
os.chown(output_directory, self.runas_uid, self.runas_gid)
581+
537582
return output_directory
538583

539584
def cleanup_extracted_files(self, tf=None):
@@ -826,6 +871,9 @@ def _dd(self, file_name, offset, size, extension, output_file_name=None):
826871
# Cleanup
827872
fdout.close()
828873
fdin.close()
874+
875+
# Make sure run-as user can access this file
876+
os.chown(fname, self.runas_uid, self.runas_gid)
829877
except KeyboardInterrupt as e:
830878
raise e
831879
except Exception as e:
@@ -846,7 +894,6 @@ def execute(self, cmd, fname, codes=[0, None]):
846894
847895
Returns True on success, False on failure, or None if the external extraction utility could not be found.
848896
'''
849-
tmp = None
850897
rval = 0
851898
retval = True
852899
command_list = []
@@ -865,16 +912,10 @@ def execute(self, cmd, fname, codes=[0, None]):
865912
retval = False
866913
binwalk.core.common.warning("Internal extractor '%s' failed with exception: '%s'" % (str(cmd), str(e)))
867914
elif cmd:
868-
# If not in debug mode, create a temporary file to redirect
869-
# stdout and stderr to
870-
if not binwalk.core.common.DEBUG:
871-
tmp = tempfile.TemporaryFile()
872-
873915
# Generate unique file paths for all paths in the current
874916
# command that are surrounded by UNIQUE_PATH_DELIMITER
875917
while self.UNIQUE_PATH_DELIMITER in cmd:
876-
need_unique_path = cmd.split(self.UNIQUE_PATH_DELIMITER)[
877-
1].split(self.UNIQUE_PATH_DELIMITER)[0]
918+
need_unique_path = cmd.split(self.UNIQUE_PATH_DELIMITER)[1].split(self.UNIQUE_PATH_DELIMITER)[0]
878919
unique_path = binwalk.core.common.unique_file_name(need_unique_path)
879920
cmd = cmd.replace(self.UNIQUE_PATH_DELIMITER + need_unique_path + self.UNIQUE_PATH_DELIMITER, unique_path)
880921

@@ -885,9 +926,10 @@ def execute(self, cmd, fname, codes=[0, None]):
885926
# command with fname
886927
command = command.strip().replace(self.FILE_NAME_PLACEHOLDER, fname)
887928

888-
binwalk.core.common.debug("subprocess.call(%s, stdout=%s, stderr=%s)" % (command, str(tmp), str(tmp)))
889-
rval = subprocess.call(shlex.split(command), stdout=tmp, stderr=tmp)
929+
# Execute external extractor
930+
rval = self.shell_call(command)
890931

932+
# Check the return value to see if extraction was successful or not
891933
if rval in codes:
892934
retval = True
893935
else:
@@ -909,7 +951,61 @@ def execute(self, cmd, fname, codes=[0, None]):
909951
binwalk.core.common.warning("Extractor.execute failed to run external extractor '%s': %s, '%s' might not be installed correctly" % (str(cmd), str(e), str(cmd)))
910952
retval = None
911953

912-
if tmp is not None:
913-
tmp.close()
914-
915954
return (retval, '&&'.join(command_list))
955+
956+
def shell_call(self, command):
957+
# If not in debug mode, redirect output to /dev/null
958+
if not binwalk.core.common.DEBUG:
959+
tmp = subprocess.DEVNULL
960+
else:
961+
tmp = None
962+
963+
# If a run-as user is not the current user, we'll need to switch privileges to that user account
964+
if self.runas_uid != os.getuid():
965+
binwalk.core.common.debug("Switching privileges to %s (%d:%d)" % (self.runas_user, self.runas_uid, self.runas_gid))
966+
967+
# Fork a child process
968+
child_pid = os.fork()
969+
if child_pid is 0:
970+
# Switch to the run-as user privileges, if one has been set
971+
if self.runas_uid is not None and self.runas_gid is not None:
972+
os.setgid(self.runas_uid)
973+
os.setuid(self.runas_gid)
974+
else:
975+
# child_pid of None indicates that no os.fork() occured
976+
child_pid = None
977+
978+
# If we're the child, or there was no os.fork(), execute the command
979+
if child_pid in [0, None]:
980+
binwalk.core.common.debug("subprocess.call(%s, stdout=%s, stderr=%s)" % (command, str(tmp), str(tmp)))
981+
rval = subprocess.call(shlex.split(command), stdout=tmp, stderr=tmp)
982+
983+
# A true child process should exit with the subprocess exit value
984+
if child_pid is 0:
985+
sys.exit(rval)
986+
# If no os.fork() happened, just return the subprocess exit value
987+
elif child_pid is None:
988+
return rval
989+
# Else, os.fork() happened and we're the parent. Wait and return the child's exit value.
990+
else:
991+
return os.wait()[1]
992+
993+
def symlink_sanitizer(self, file_list, extraction_directory):
994+
# User can disable this if desired
995+
if self.do_not_sanitize_symlinks is True:
996+
return
997+
998+
# Allows either a single file path, or a list of file paths to be passed in for sanitization.
999+
if type(file_list) is not list:
1000+
file_list = [file_list]
1001+
1002+
# Sanitize any files in the list that are symlinks outside of the specified extraction directory.
1003+
for file_name in file_list:
1004+
if os.path.islink(file_name):
1005+
linktarget = os.path.realpath(file_name)
1006+
binwalk.core.common.debug("Analysing symlink: %s -> %s" % (file_name, linktarget))
1007+
1008+
if not linktarget.startswith(extraction_directory) and linktarget != os.devnull:
1009+
binwalk.core.common.warning("Symlink points outside of the extraction directory: %s -> %s; changing link target to %s for security purposes." % (file_name, linktarget, os.devnull))
1010+
os.remove(file_name)
1011+
os.symlink(os.devnull, file_name)
270 KB
Binary file not shown.

testing/tests/test_dirtraversal.py

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import os
2+
import binwalk
3+
from nose.tools import eq_, ok_, assert_equal, assert_not_equal
4+
5+
def test_dirtraversal():
6+
'''
7+
Test: Open dirtraversal.tar, scan for signatures.
8+
Verify that dangerous symlinks have been sanitized.
9+
'''
10+
bad_symlink_file_list = ['foo', 'bar', 'subdir/foo2', 'subdir/bar2']
11+
good_symlink_file_list = ['subdir/README_link', 'README2_link']
12+
13+
input_vector_file = os.path.join(os.path.dirname(__file__),
14+
"input-vectors",
15+
"dirtraversal.tar")
16+
17+
output_directory = os.path.join(os.path.dirname(__file__),
18+
"input-vectors",
19+
"_dirtraversal.tar.extracted")
20+
21+
scan_result = binwalk.scan(input_vector_file,
22+
signature=True,
23+
extract=True,
24+
quiet=True)[0]
25+
26+
# Make sure the bad symlinks have been sanitized and the
27+
# good symlinks have not been sanitized.
28+
for symlink in bad_symlink_file_list:
29+
linktarget = os.path.realpath(os.path.join(output_directory, symlink))
30+
assert_equal(linktarget, os.devnull)
31+
for symlink in good_symlink_file_list:
32+
linktarget = os.path.realpath(os.path.join(output_directory, symlink))
33+
assert_not_equal(linktarget, os.devnull)

testing/tests/test_firmware_zip.py

-2
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ def test_firmware_zip():
1010
'''
1111
expected_results = [
1212
[0, 'Zip archive data, at least v1.0 to extract, name: dir655_revB_FW_203NA/'],
13-
[51, 'Zip archive data, at least v2.0 to extract, compressed size: 6395868, uncompressed size: 6422554, name: dir655_revB_FW_203NA/DIR655B1_FW203NAB02.bin'],
14-
[6395993, 'Zip archive data, at least v2.0 to extract, compressed size: 14243, uncompressed size: 61440, name: dir655_revB_FW_203NA/dir655_revB_release_notes_203NA.doc'],
1513
[6410581, 'End of Zip archive, footer length: 22'],
1614

1715
]

0 commit comments

Comments
 (0)