h1-ctf: [H1-2006 2020] CTF writeup

ID H1:892632
Type hackerone
Reporter agent_p
Modified 2020-06-18T15:30:51



Well, against all expectations you finally get it, you got the flag! Let's go back in time to remember how.


~~Once upon a time~~ As always the CTF starts with a tweet: {F855948}


According to the policy page, *.bountypay.h1ctf.com is in scope. You decide to scan subdomains, here are the results: * https://www.bountypay.h1ctf.com: public facing dashboard for bountypay. Have links to a app.bountypay.com and staff.bountypay.com * https://app.bountypay.h1ctf.com: can't tell much about this, you are just facing a login form * https://staff.bountypay.h1ctf.com: same login portal here, according to the name of the subdomain, you can guess it will be a bit harder to log into as in the previous one * https://api.bountypay.h1ctf.com: probably used by the backend. You can only access the homepage because all endpoints require a token that you don't have. * https://software.bountypay.h1ctf.com: forbidden to your IP address; you'll probably need some kind of internal proxying such as SSRF to access that


Scanning all those previous domains for known locations leads to a .git folder on https://app.bountypay.h1ctf.com/.git that returns a forbidden error (directory listing is disabled here). You decide to use git-dumper to retrieve the folder content. python git-dumper.py https://app.bountypay.h1ctf.com/.git/ /ctf100m {F855972}

Credentials disclosure via log file

After analysing those files, you notice a remote repo URL on .git/config: https://github.com/bounty-pay-code/request-logger.git

The git repo only contains a logger.php file that appears to log requests in the https://app.bountypay.h1ctf.com/bp_web_trace.log {F855974}

Right, the log file shows 4 requests that we can just base64 decode. Logs are not coming anymore, so this is just a static file. {F855974}

Base64 decoding one of those gives you your first credentials on https://app.bountypay.h1ctf.com: {"IP":"","URI":"\/","METHOD":"POST","PARAMS":{"GET":[],"POST":{"username":"brian.oliver","password":"V7h0inzX"}}}

First 2FA bypass

Using those credentials, you're able to log into https://app.bountypay.h1ctf.com... Well, almost; the page shows a 2FA screen and asks us to enter the 10 chars answer to a challenge: {F855986}

After analysing the POST parameters of the answer request, you notice a hash that looks like md5 along the challenge answer. You pick a random 10 chars string respecting the charset (aZ23456789) and md5 hash it (0e126c59db5e4c66ee9fed8b0930587b). Finally, replacing POST parameters by username=brian.oliver&password=V7h0inzX&challenge=0e126c59db5e4c66ee9fed8b0930587b&challenge_answer=aZ23456789 lets you bypass the 2FA and access the app. {F856004}

(Path traversal + "Open" redirect) to SSRF

Path traversal

The app is very light and the only features you have access to are logout and some kind of XHR-loading transactions: {F856009}

After trying to bruteforce months and years to look for any transaction (and not finding anything) and trying to test those parameters against SQLI, you take a closer look at the response and notice that the server answer with what appears to be the hit API endpoint: {"url":"https:\/\/api.bountypay.h1ctf.com\/api\/accounts\/F8gHiqSdpK\/statements?month=03&year=2020","data":"{\"description\":\"Transactions for 2020-03\",\"transactions\":[]}"}

At the same time, you notice that your token cookie is a base64 encoded json object: {"account_id":"F8gHiqSdpK","hash":"de235bffd23df6995ad4e0930baac1a2"} Again the same F8gHiqSdpK, you change it for ../../../ and issue a new request: {F856017} Bingo! You're now able to manipulate the backend API request to any https://api.bountypay.h1ctf.com/* endpoint.

"Open" redirect to SSRF

The API subdomain homepage contains a link to https://api.bountypay.h1ctf.com/redirect?url=https://www.google.com/search?q=REST+API After testing this endoint for open redirect, you find out it's whitelist based: url parameter must start with a whitelisted pattern: * https://api.bountypay.h1ctf.com/redirect?url=https://www.google.com/search?q=REST+API is VALID * https://api.bountypay.h1ctf.com/redirect?url=https://www.google.com/search?q=REST+API+SUFFIX is also VALID * https://api.bountypay.h1ctf.com/redirect?url=https://evil.com is NOT VALID

You immediately find out that this open redirect paired with the previous exploit can let you access https://software.bountypay.h1ctf.com from an internal IP! After dumbly assuming that https://software.bountypay.h1ctf.com must not be whitelisted and trying to bypass the whitelist for a while, you find out that https://software.bountypay.h1ctf.com IS whitelisted and you promise yourself to stop avoiding to test your bad assumptions. Changing your token for {"account_id":"../../redirect?url=https://software.bountypay.h1ctf.com/#","hash":"de235bffd23df6995ad4e0930baac1a2"} will make the backend hit https://api.bountypay.h1ctf.com/api/accounts/../../redirect?url=https://software.bountypay.h1ctf.com/#/statements?month=03&year=2020 and will return the HTML of the page: {F856044}

Dirbusting to APK

Crap! Again a login screen. As the login form appears to be POST based and you can only issue GET request using our SSRF tunnel, you can't use it and you write your own ~~trashy~~ dirbuster-like that will map the subdomain throught your brand new SSRF tunnel. {F856052}

It's incredibly slow as you were too lazy to make it multi threaded, but fortunately results came very quickly and discloses a listeable directory that contains an APK: {F856056}

After failing to get the APK through the SSRF tunnel (for some reason the content is not returned back), in a burst of despair you request it without using the tunnel and the application surprisingly starts downloading (you were thinking the IP restriction was subdomain based ~ again a bad assumption, yes, you quickly failed to keep your previous promise)!

BountyPay.apk - An ADB tale

Let's install and decompile the APK! Analysing AndroidManifest.xml let you get an eye on the app; there are four activities: * MainActivity - Contains fields for username/twitter - Must be filled to continue * PartOneActivity - First step - handle one://part/* deeplinks * PartTwoActivity - Second step - handle two://part/* deeplinks * PartThreeActivity - Last step - handle three://part/* deeplinks

First step - PartOneActivity

java if (getIntent() != null && getIntent().getData() != null) { String firstParam = getIntent().getData().getQueryParameter("start"); if (firstParam != null && firstParam.equals("PartTwoActivity") && settings.contains(str)) { String str2 = ""; String user = settings.getString(str, str2); Editor editor = settings.edit(); String twitterhandle = settings.getString("TWITTERHANDLE", str2); editor.putString("PARTONE", "COMPLETE").apply(); logFlagFound(user, twitterhandle); startActivity(new Intent(this, PartTwoActivity.class)); } } Using sources (PartOneActivity.java), this step is pretty straightforward; you have to pass a start parameter to the one://part/* deeplink that is equal to "PartTwoActivity": This link will solve the first step and bring us to the second activity: one://part/?start=PartTwoActivity

Second step - PartTwoActivity

java 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")) { editText.setVisibility(0); button.setVisibility(0); textview.setVisibility(0); } } Again, analysing the sources will bring you to the two://part/?two=light&switch=on deeplink that will make some UI appearing.

Sources again tells you that you have to enter X-[value] into the textedit. java public void onDataChange(DataSnapshot dataSnapshot) { String value = (String) dataSnapshot.getValue(); SharedPreferences settings = PartTwoActivity.this.getSharedPreferences(PartTwoActivity.KEY_USERNAME, 0); Editor editor = settings.edit(); String str = post; StringBuilder sb = new StringBuilder(); sb.append("X-"); sb.append(value); if (str.equals(sb.toString())) { String str2 = ""; PartTwoActivity.this.logFlagFound(settings.getString("USERNAME", str2), settings.getString("TWITTERHANDLE", str2)); editor.putString("PARTTWO", "COMPLETE").apply(); PartTwoActivity.this.correctHeader(); return; } Toast.makeText(PartTwoActivity.this, "Try again! :D", 0).show(); } Getting the value of value is very easy using the integrated debugger in android studio. The real challenge for you is to make the debugger breakpoints work properly! You struggled a lot and epically fought against ADB during hours, but finally succeeded in getting the wanted value: Token. Entering X-Token will guide you to the last activity.

Third step - PartThreeActivity

```java Uri data = getIntent().getData(); String firstParam = data.getQueryParameter("three"); String secondParam = data.getQueryParameter("switch"); String thirdParam = data.getQueryParameter("header"); byte[] decodeFirstParam = Base64.decode(firstParam, 0); byte[] decodeSecondParam = Base64.decode(secondParam, 0); final String decodedFirstParam = new String(decodeFirstParam, StandardCharsets.UTF_8); final String decodedSecondParam = new String(decodeSecondParam, StandardCharsets.UTF_8); AnonymousClass5 r17 = r0; DatabaseReference databaseReference = this.childRefThree; byte[] bArr = decodeSecondParam; final String str = firstParam; byte[] bArr2 = decodeFirstParam; final String str2 = secondParam; String str3 = secondParam; final String secondParam2 = thirdParam; String str4 = firstParam; final EditText editText2 = editText; Uri uri = data; final Button button2 = button; AnonymousClass5 r0 = new ValueEventListener() { public void onDataChange(DataSnapshot dataSnapshot) { String value = (String) dataSnapshot.getValue(); if (str != null && decodedFirstParam.equals("PartThreeActivity") && str2 != null && decodedSecondParam.equals("on")) { String str = secondParam2; if (str != null) { StringBuilder sb = new StringBuilder(); sb.append("X-"); sb.append(value); if (str.equals(sb.toString())) { editText2.setVisibility(0); button2.setVisibility(0); PartThreeActivity.this.thread.start(); } } } }

public void onCancelled(DatabaseError databaseError) {
    Log.e("TAG", "onCancelled", databaseError.toException());

}; ``` Some Base64 encoding here, but nothing that difficult, three://part?three=UGFydFRocmVlQWN0aXZpdHk=&switch=b24=&header=X-Token will make a textedit appear again.

The X-Token value will be simply leaked in the Logs, you just perform some complex hacking wizardry (CTRL + F to locate the right log) and finally get the leaked header value. java sb.append("X-Token: "); sb.append(paramValue); Log.d("HEADER VALUE AND HASH ", sb.toString()); {F856099}

And that's the end of the APK part! {F856102}

API time !

New endpoints

There is not so many places where the leaked header X-Token: 8e9998ee3137ca9ade8f372739f062c1 can be used; the api subdomain! The statement endpoint doesn't help you much and you will have to find new endpoint. A quick list based endpoint bruteforce gives you two new endpoints: * GET /api/accounts/id/ - gives info about the [id] user, not so useful as the only user id you know is brian.olivier * GET /api/staff - returns [{"name":"Sam Jenkins","staff_id":"STF:84DJKEIP38"},{"name":"Brian Oliver","staff_id":"STF:KE624RQ2T9"}]

The staff endpoint looks interesting, changing the request method to POST and adding a staff_id POST parameter seems to try creating a staff account for the given staff_id. Unfortunately, both staff_id returned by the GET request was already created...

The social vector - Thank you Sandra

As procrastinating is your favorite hobby, you've already browsed the entire twitter twice and noticed the @BountyHQ twitter account before. Earlier you used this tweet to bruteforce every login form with the "sandra"/"Sandra" username without success. {F856118}

Giving a look at the followers of @BountyHQ leads you to Sandra's account. One of her tweet discloses her staff_id on her badge: STF:8FJ3KFISL3 {F856257}

Using this new staff_id in the /staff endpoint gives you staff creds: {"description":"Staff Member Account Created","username":"sandra.allison","password":"s%3D8qB8zEpMnc*xsz7Yp5"}

Privilege escalation from staff to admin

You can now log into https://staff.bountypay.h1ctf.com/?template=home.

This application looks interesting, let's sum up: * There is a Profile tab where you can change your Profile name and your Avatar * You can review your tickets on the Support Tickets tab * There is a feature to report the current URL that will be reviewed by Admin user (i.e: he will browse the URL)

Privilege upgrading endpoint

The https://staff.bountypay.h1ctf.com/js/website.js file catch your attention: javascript $(".upgradeToAdmin").click(function() { let t = $('input[name="username"]').val(); $.get("/admin/upgrade?username=" + t, function() { alert("User Upgraded to Admin") }) }) If you could make the Admin load https://staff.bountypay.h1ctf.com/admin/upgrade?username=sandra.allison it seems that sandra's account would get upgraded.

Sending privilege upgrading request on page load

The JS file also includes a useful feature: when it detects a #tab[1234], it will emulate a click event on the DOM element which have the corresponding class name. javascript && ("#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"));

Using the Profile tab, you can change the Sandra's name and avatar. Unfortunately XSS filters are in place and it will be a little more complicated than loading the endpoint with a src attribute. However, avatars are weirdly handled: they are just classes and you can basically set any class to your avatar element using burp! The idea is to set the "upgradeToAdmin" and the "tab3" class to the avatar element to emulate a click event on it on page load, also triggering the click listener of "upgradeToAdmin" and sending the upgrade request automatically!

{F856316} And it gets reflected back as planned: html <div style="margin:auto" class="avatar avatar1 upgradeToAdmin tab3"></div>

From there, loading https://staff.bountypay.h1ctf.com/?template=ticket&ticket_id=3582#tab3 will trigger an automated request to https://staff.bountypay.h1ctf.com/admin/upgrade?username=undefined

Setting our username

The username is picked from the input $('input[name="username"]'). There are only two pages where there is such an input: profile edit tab and login form. We can't set the value of this field on admin profile edit (it would be 'Admin' on his side) so there is only the login form left.

It's interesting to note that you can access https://staff.bountypay.h1ctf.com/?template=login even if we are logged in. Adding a GET parameter username allows us to set the field content on load: https://staff.bountypay.h1ctf.com/?template=login&username=sandra.allison

Pairing login and evil payload

The template GET parameter can be set twice at once: you can load two templates in a single request: https://staff.bountypay.h1ctf.com/?template[]=home&template[]=login

From there, you just have to set it all together: https://staff.bountypay.h1ctf.com/?template[]=ticket&ticket_id=3582&template[]=login&username=sandra.allison#tab3 Loading this URL will issue a request to https://staff.bountypay.h1ctf.com/admin/upgrade?username=sandra.allison, PERFECT!

Sending it to admins

Last step is to send your evil URL to Admins using the page report feature. So you report a random page and catch the outgoing using burp: https://staff.bountypay.h1ctf.com/admin/report?url=Lz90ZW1wbGF0ZT10aWNrZXQmdGlja2V0X2lkPTM1ODI= url parameter is base64 encoded (Lz90ZW1wbGF0ZT10aWNrZXQmdGlja2V0X2lkPTM1ODI= => /?template=ticket&ticket_id=3582)

Base64 encoding ours: ?template[]=ticket&ticket_id=3582&template[]=login&username=sandra.allison#tab3 => P3RlbXBsYXRlW109dGlja2V0JnRpY2tldF9pZD0zNTgyJnRlbXBsYXRlW109bG9naW4mdXNlcm5hbWU9c2FuZHJhLmFsbGlzb24jdGFiMw==

Last step is to put it all together and requesting https://staff.bountypay.h1ctf.com/admin/report?url=P3RlbXBsYXRlW109dGlja2V0JnRpY2tldF9pZD0zNTgyJnRlbXBsYXRlW109bG9naW4mdXNlcm5hbWU9c2FuZHJhLmFsbGlzb24jdGFiMw==

Here you are! Escalated to Admin! {F856335}

Helping Marten

You grabbed Marten's logging credentials for https://app.bountypay.h1ctf.com/. After bypassing the login 2FA again, you can now see the famous may transaction: {F856346} Clicking the Pay button show you another 2FA form, but this time it's not that simple and hand crafting challenge doesn't work anymore...

CSS injection principle

When sending the 2FA request, there is a hidden POST parameter app_style=https://www.bountypay.h1ctf.com/css/uni_2fa_style.css which is passed. This file is a regular CSS file and seems to be used as the 2FA app stylesheet on marten side. You decide to leverage this CSS to exfiltrate the 2FA code from the app using some kind of black magic you saw before.

The idea is to exfiltrate attribute value using multiple CSS selectors that target elements that have an attribute starting by a certain string (a selector for each element of the possible charset). Those selectors will apply a CSS rule (for this time, it'll be background-image) that will hit attacker's webhook only if an element that matches this selector exists in the DOM. By repeating this enough times, you'll be able to extract the whole attribute value.

Analyzing DOM

Here comes the {F856527}! This tool automates the CSS payload generation given the prefix. If you want to give it a try, please don't forget to set your own collaborator URL. This one is designed to extract "name" attributes, but you can change the target attribute easily. Repeating the 2FA code send request with the URL pointing to the generated payload will log every page load with a witness request (https://collaborator/LOADED_NONEXISTING (don't try to understand the name, I don't even know why I named it like that)) and https://collaborator/[ATTRIBUTE_VALUE].png for each matching attribute.

With this technique you were able to extract 6 different names ranging from code_1 to code_6.

Grabbing code - No, wait, there's MORE

With the new info, you design a value extractor: {F856536} This works similarly excepted that this time, you are scanning 6 different elements and they are all 1 char long only.

Yeah, looks like some easy stuff no? {F856543}

From what I can tell, each code only has a probability of 50% to spawn on the page. That means that you will have the lucky 6 chars only 1.5% of the times. (0.5^6=0.015625)

BUT that's not all BECAUSE

According to the maxlenght of the 2FA answer form, you can tell that the expected code is 7 char long so there is a missing character that you will have to guess.

{F856542} You only have 2 minutes between the moment you fire the send 2FA code request and the moment you answer the challenge.

Here is a summary: {F856932}

Solving - Final Boss

After 15 minutes sending requests trying to grab the lucky 6 chars, 7 requests pops out (witness request + 6 digits)! You know you time it's counted and you only have 1 minutes and 50 seconds left. Your hands are shaking, clicking collaborator requests one after another seems so slow but you finally grab the lucky 6: the 2FA code contains cuwZ66 - 1 minute and 25 seconds left! You've prepared your intruder before, so you just have to copy/paste challenge, challenge_answer and challenge_timeout. You assume the missing character is the last one and launch the attack - 50 seconds left. Finally, a different request shows up - here you are Martens! {F856933} {F856935} ^FLAG^736c635d8842751b8aafa556154eb9f3$FLAG$


An attacker could finish the CTF and have good time