Writeup - KnightCTF 2025 Rev Challenges | My Sword is Binja, My Shield is GEF


January 24 2024 - Writeups for all KnightCTF Rev Challenges

KnightCTF

I recently competed in KnightCTF 2025 for shits and giggles, and I wanted to do the rev challenges because I’ve been wanting to improve my reverse-engineering skills?

This CTF was fun and the challenges, while not overly complex, were a great refresher on basic reverse engineer methodologies. I ended up being too busy to do one or two of the challenges in time, so I ended up doing those on my own afterwards.

Also, I try to put the challenge creator in these to give them credit, but after the challenges were over they took them down basically instantly, and their URL 403’s me when I try to look at them.

Knight’s Droid

Basic APK Reversing.

Chall Description

For ages, a cryptic mechanical guardian has slumbered beneath the Knight’s Citadel. 
Some say it holds powerful secrets once wielded by ancient code-wielding Knights. 
Many have tried to reactivate the droid and claim its hidden knowledge—yet none have returned victorious. 
Will you be the one to solve its riddles and awaken this legendary machine?

The challenge consisted of an apk file. Usually, when I see APKs, I use jadx or JD-GUI to decompile them. They’re solid, easy to use, and can handle most APK reverse-engineering. In this case, I used jadx.

APKs can be easily decompiled because they’re packaged in a format that includes compiled bytecode (DEX files), and all other resources the app uses (images, config files, etc.), and they aren’t obfuscated or protected by default. DEX bytecode is easily converted back to readable formats by the above applications, and APKs aren’t inherently protected in any way. I have seen a rise in the number of obfuscated APKs, but overall the trend is still to not heavily protect the APK.

1. Browsing the source code

Looking around, the first thing I’ll take a look at is the com/knightctf.knights_droid folder, as that’s where the meat of the app is (usually) stored. I see several classes, and go through them. The first interesting one for me is MainActivity, where I see the flag checking being performed.

Taking a closer look, we see a class called SecretKeyVerifier being used, which looks like this.

2. Shifts and Ciphers

Okay, cool. We have a couple strings here, most notably the GYPB{_ykjcnwp5_GJECDP_u0q_c0p_uKqN_Gj1cd7_zN01z_} string. We perform the computeShiftFromKey function on this string, passing in a firstTen variable. Looking at the rest of the code, the app takes in the first 10 characters of context.getPackageName();, which should be com.knight. So it takes the first ten letters of the package name, and passes them into this function

private static int computeShiftFromKey(String key) {
    int sum = 0;
    for (char c : key.toCharArray()) {
        sum += c;
    }
    return sum % 26;
}

and then takes the number computed and passes it into this function

private static String droidMagic(String input, int droidTask) {
    int droidTask2 = ((droidTask % 26) + 26) % 26;
    StringBuilder sb = new StringBuilder();
    for (char c : input.toCharArray()) {
        if (Character.isUpperCase(c)) {
            int originalPos = c - 'A';
            int newPos = (originalPos + droidTask2) % 26;
            sb.append((char) (newPos + 65));
        } else if (Character.isLowerCase(c)) {
            int originalPos2 = c - 'a';
            int newPos2 = (originalPos2 + droidTask2) % 26;
            sb.append((char) (newPos2 + 97));
        } else {
            sb.append(c);
        }
    }
    return sb.toString();
}

3. Shifting Sands

Just by looking at droidMagic, I can tell it performs a basic character shift, between 0 and 25 (26 characters in the alphabet).

For each character in the input string:
If the character is an uppercase letter, it shifts its position within the uppercase alphabet (A-Z) by droidTask2 positions.
If the character is a lowercase letter, it shifts its position within the lowercase alphabet (a-z) by the same amount.
Any non-alphabetic character (e.g., spaces, punctuation) is left unchanged.

While I can create a solve script to figure this out, I can also just bruteforce this. It’s a rotation cipher, so I stick it in cyberchef, and go up and down the rotations until I see that it’s ROT4. Cyberchef hands us the flag

cyberchef output

KCTF{_congrat5_KNIGHT_y0u_g0t_yOuR_Kn1gh7_dR01d_}

Binary Quest

Protection Bypassing, or basic string lookup

Chall Description

In the far-off kingdom of Valoria, an ancient relic called the “Sacred Flag” lies hidden within a guarded fortress. 
Legend says only a true knight of cunning and skill can lay claim to its power. 
Dare you venture into the shadows and emerge victorious? 
Your journey begins now—onward, brave soul, and seize your destiny in the Binary Quest.

We’re given a binary file.

Step 1. USPS? UPS? Nah, UPX.

When looking at the strings, we can see that it’s packed by UPX.
This file is packed with the UPX executable packer.

We also see what seems to be the flag.

partial flag

That’s interesting, but that flag seems to be a partial one. Let’s actually unpack this thing.

upx -d ./binary.quest gives us the unpacked binary.

Step 2. The binary

First, knowing that the partial flag may exist, I take a look at where it’s located. I see that the main() function calls strcpy() and strncpy() on it. Aaaaaand Binja automatically displays it. Nice.

binja found the flag

Flag is KCTF{_W4s_i7_e4sY?_}

But let’s try to remove the debugging check, for shits and giggles (thanks for the idea @abl_).

Bonus: PTRACE? More like NOPTRACE.

So in the main function, we call another function before anything else is done, except the basic output. This function, sub_12e0(), performs a debugging check. It calls ptrace to check for a debugger, and exits if it sees a debugger.

int64_t sub_12e0()

    int64_t result = ptrace(request: PTRACE_TRACEME, 0, 1, 0)
    
    if (result != -1)
        return result
    
    puts(str: "\nThe King's Watchers sense pryi…")
    puts(str: "You have been discovered! The ch…")
    exit(status: 1)
    noreturn

The dissassembly looks like this:

sub_12e0:
    sub     rsp, 0x8
    xor     ecx, ecx  {0x0}
    xor     esi, esi  {0x0}
    xor     edi, edi  {0x0}
    xor     eax, eax  {0x0}
    mov     edx, 0x1
    call    ptrace
    cmp     rax, 0xffffffffffffffff
    je      data_1300[1]

    add     rsp, 0x8
    retn     {__return_addr}

    lea     rdi, [rel data_2008]  {"\nThe King's Watchers sense pryi…"}
    call    puts
    lea     rdi, [rel data_2040]  {"You have been discovered! The ch…"}
    call    puts
    mov     edi, 0x1
    call    exit

We can do a lot here to bypass the check.

  1. We can patch the cmp rax, 0xffffffffffffffff instruction to be cmp rax, 0, which would effectively bypass the branch from ever being taken, because rax would no longer equal 0xffffffffffffffff.
  2. We could also patch the call ptrace instruction to a nop, bypassing the syscall.
  3. We could also modify the rax register after the ptrace call by inserting an instruction like mov rax, 0x0, resulting in a successful ptrace call.
  4. We could also replace the jump instruction with a nop instruction.

Anyways, I modified the file. Now, we can use ltrace and the flag will be used in the strlen instruction.

Easy Path to the Grail

Reversing bit orders and hex encodings.

Brave knight, your quest is simple yet essential—unlock the secrets hidden in this binary challenge and tread the path to the grail. The journey will test your wits as you reverse the provided binary, uncovering the treasure within.

Again, we’re given a binary file.

This one isn’t packed with UPX, but they also haven’t stripped many of the symbols. This is nice!

Step 1. The Binary

Going through the main() function, I rename variables based on behaviour, and get a better understanding of what’s going on.

main function

It seems that we’re taking the user input, running teh tranform_input() function on it, and then comparing the output to D2C22A62DEA62CCE9EFA0ECC86CE9AFA4ECC6EFAC6162C3636CC76E6A6BE.

Cool, so the tranform_input() function is what we actually need to reverse.

Step 2. Transformers (not in the ai sense)

The transform_input() looks like this. I’ve commented what each important line does.

char* transform_input(char* input, char* output)

{
    char* input_ptr = input;
    char* output_ptr = output;
    
    while (*(uint8_t*)input_ptr) // Loops until null terminator is reached
    {
        sprintf(output_ptr, "%02X", (uint64_t)do_fight(*(uint8_t*)input_ptr), "%02X"); // The `sprintf` line takes in the current byte of input and runs the `do_fight()` function on it.
        output_ptr = &output_ptr[2]; // Since do_fight() output is hex format, we move the ptr forward 2 bytes to write the next 2 hex digits
        input_ptr = &input_ptr[1]; // We move the ptr forward 1 byte to the next char in input
    }
    
    *(uint8_t*)output_ptr = 0; // Add a null terminator to the end of output string
    return output_ptr;
}

Okay, so all that transform_input() does is take the input string, run do_fight() on each character, format the do_fight() output to a hex code, and build an output string containing that. Cool. Let’s take a look at do_fight()

Step 3: Final round. Fight!

The do_fight() function looks like this.

uint64_t do_fight(uint8_t arg1) __pure

{
    uint8_t var_1c = arg1;
    char var_d = 0;
    
    for (int32_t i = 0; i <= 7; i += 1)
    {
        var_d = var_d << 1 | (var_1c & 1);
        var_1c u>>= 1;
    }
    
    return (uint64_t)var_d;
}

I cleaned it up.

uint64_t do_fight(uint8_t inputChar) __pure

{
    uint8_t inputCharTMP = inputChar;
    char reversedBits = 0;
    
    for (int32_t i = 0; i <= 7; i += 1)  // 8 bytes total
    {
      // Shift the current reversed bits in reversedBits to the left by 1 then add the LSB of inputCharTMP to reversedBits.
      reversedBits = reversedBits << 1 | (inputCharTMP & 1);

      // Right shift inputCharTMP to process the next bit 
      inputCharTMP u>>= 1;
    }
    
    return (uint64_t)reversedBits;
}

What this does, essentially, is take the input character as an 8-bit input, and reverses the order of its bits.
For each of the 8 bits in inputChar, we shift the current bit in reversedBits left by 1 to make space for the new bit.
Then, we use a bitwise AND & 1 to extract the right-most bit of inputChar, add it to reversedBits, and then shift inputChar by 1 to loop around and perform the same task on the next bit.

Let’s reverse this process. I made a python script to do it.

output = 'D2C22A62DEA62CCE9EFA0ECC86CE9AFA4ECC6EFAC6162C3636CC76E6A6BE'

byteArr = bytes.fromhex(output)

revBytes = []

for byte in byteArr:
    reversedByte = 0
    for i in range(8):
        # Shift reversed_byte left by 1 and append the LSB of byte
        reversedByte = (reversedByte << 1) | (byte & 1)
        # Shift byte right by 1
        byte >>= 1
    revBytes.append(reversedByte)

reversed_string = bytes(revBytes).decode('ascii')

print(f"Reversed string: {reversed_string}")

And our flag is KCTF{e4sy_p3asY_r3v_ch4ll3nge}

Knight’s Enigma

Reversing bit orders and hex encodings.

In the shadowed corridors of an ancient fortress, a legendary knight once safeguarded a secret so potent that countless contenders have vanished trying to decipher it. 
Now the seal has cracked, and echoes of its power seep into the present. 
Test your courage as you follow cryptic traces left by the knight’s hand, unraveling an enigma steeped in the mysticism of ages past.
Will your wits prove enough to break the bindings and uncover the realm’s hidden legacy—or will you, too, fade into the swirling mists of history? 
The choice—and fate—are yours to determine.

Again, we’re given a binary file.

This one has a massive main() function, and it seems to be written in rust, considering the methodologies it uses. However, a lot of the code here isn’t necessarily important. Looking at our code, our win condition seems to be getting a successful memcmp() on two variables.

main

Step 1. Dast, Rust, Dust

After some going back and forth, I realised that we could ignore a lot of the SIMD operations and memory modifications, as this was the important part of our code. After attaching gdb and running through this code a couple times, I realized that, outside of memory operations, these bits of code were what actually modified anything.

if (rdx_32:1.b == 0) // or if (rdx_14:1.b == 0)
    rax_11 = mods.dp.d(sx.q(rax_11 - 0x60), 0x1a) + 0x61
else
    rax_11 = mods.dp.d(sx.q(rax_11 - 0x40), 0x1a) + 0x41
char rdx_43 = (rax_11.b u>> 4 & 1) | (((rax_11.b u>> 3 & 1) | (((rax_11.b u>> 2 & 1) | ((((rax_11.b * 2) & 2) | (rax_11.b u>> 1 & 1)) * 2)) * 2)) * 2)
char rcx_12 = (rax_11.b u>> 5 & 1) | (rdx_43 * 2)
uint8_t rdx_45 = rax_11.b
rax_11.b u>>= 7
char var_87_1 = (rax_11.b | (((rdx_45 u>> 6 & 1) | (rcx_12 * 2)) * 2)) ^ 0xaa

It just performed these actions, rinse and repeat, on each value in each register.