This article will cover my write-ups for the reverse engineering challenges done during KnightCTF 2025. If you are interested in doing these challenges, they are available at this address.

Knight’s Droid Link to heading

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?

Flag Format : KCTF{s0mething_here}

First, we unpack the APK file using the following command: unzip knightdroid -d unpacked/

Then, we navigate to the directory where we placed our unpacked APK files.

Fortunately, there are only a few classes.dex files, so by quickly reviewing each of them, we identify that classes3.dex contains some useful information. It includes two Java files: MainActivity.java, which is typically the entry point of an Android APK, and SecretKeyVerifier.java, which is likely where the key provided by the user is verified.

Let’s take a look at some code:

MainActivity.java:

public void onClick(View v) {
    String inputKey = MainActivity.this.secretKeyInput.getText().toString().trim();
    if (inputKey.isEmpty()) {
        Toast.makeText(MainActivity.this, "Please enter a key!", 0).show();
    } else if (SecretKeyVerifier.verifyFlag(MainActivity.this, inputKey)) {
        Toast.makeText(MainActivity.this, "Congrats Knight!", 0).show();
    } else {
        Toast.makeText(MainActivity.this, "Key did not match!", 0).show();
    }
}

SecretKeyVerifier.java:

public static boolean verifyFlag(Context context, String userInput) {
    String fullPackageName = context.getPackageName();
    if (fullPackageName.length() < 20) {
        return false;
    }
    return "GYPB{_ykjcnwp5_GJECDP_u0q_c0p_uKqN_Gj1cd7_zN01z_}".equals(droidMagic(userInput, computeShiftFromKey(fullPackageName.substring(0, 10))));
}

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

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)) {
            sb.append((char) ((((c - 'A') + droidTask2) % 26) + 65));
        } else if (Character.isLowerCase(c)) {
            sb.append((char) ((((c - 'a') + droidTask2) % 26) + 97));
        } else {
            sb.append(c);
        }
    }
    return sb.toString();
}

The string GYPB{ykjcnwp5_GJECDP_u0q_c0p_uKqN_Gj1cd7_zN01z} is our flag, but it’s likely encoded using a Caesar cipher.

We tried all possible combinations for the key and decoded the flag with a key of 4 using CyberChef.

FLAG: KCTF{congrat5_KNIGHT_y0u_g0t_yOuR_Kn1gh7_dR01d}


Binary Quest Link to heading

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.

Flag Format : KCTF{s0mething_here}

We are given a binary named binary.quest. Using the strings command on it, we notice something interesting: The binary is packed using UPX

strings binary.quest 
UPX!
-9ya
tdoP7
/lib64
nux-x86-
so.2
?puts
exit
x2}y%6
3s7KCTF{_W4s_i7_e
XPGU
hLD[Lz$Lv
;& D
;*3$"
?C(i
USQRH
W^YH
PROT_EXEC|PROT_WRITE failed.
_j<X
$Info: This file is packed with the UPX executable packer http://upx.sf.net 

We can unpack it using the following command: upx -d binary.quest

Now we can begin analyzing the program’s behavior by disassembling it with ghidra.

The flag check logic is located here:

iVar1 = strcmp(local_58,local_98);
if (iVar1 == 0) {
puts("\nYou have proven your valor, oh noble knight!");
puts("The kingdom rejoices at your triumph, and the hidden flag is indeed yours.\n");
}
else {
puts("\nAlas, you have failed this time. The quest remains unfulfilled...");
puts("Return stronger and try again, brave knight.\n");
}
return 0;

Looking at these two variables, local_58 is the user input, and local_98 likely contains the flag.

Just before the flag check logic, we had some variables declared :

local_88._0_4_ = 0x7d5f3f59;
local_98._8_4_ = 0x37695f73;
local_98._0_8_ = 0x34575f7b4654434b;
uStack_8c = 0x7334655f;

We can decode and reorder these hex values to get the flag: local_88.0_4 = 0x7d5f3f59 => }?Y local_98.8_4 = 0x37695f73 => 7i_s local_98.0_8 = 0x34575f7b4654434b => 4W{FTCK uStack_8c = 0x7334655f => s4e_

FLAG: KCTF{W4s_i7_e4sY?}


Easy Path to the Grail Link to heading

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.

Flag Format : KCTF{s0mething_here}

Here, we are given a grail.knight file which is an ELF binary.

In the code generated by the decompiler, we have 3 functions of interest : main , transform_input, do_fight

Snippet of the main function which does the flag check:

printf("Enter the password (the original flag): ");
iVar1 = __isoc99_scanf("%127s",local_198);
if (iVar1 == 1) {
transform_input(local_198,local_118);
iVar1 = strcmp(local_118,"D2C22A62DEA62CCE9EFA0ECC86CE9AFA4ECC6EFAC6162C3636CC76E6A6BE");
if (iVar1 == 0) {
    puts("Wrong password!");
}
else {
    printf("Correct! The flag is %s\n",local_198);
}
uVar2 = 0;
}

In this snippet of code, the program transform input stored in local_198 which will be transformed then stored to local_118, that’s why the comparison (strcmp) is done using local_118 and not local_198(user input).

transform_input:

void transform_input(char *param_1,char *param_2)

{
  byte bVar1;
  char *local_28;
  char *local_20;
  
  local_28 = param_2;
  for (local_20 = param_1; *local_20 != '\0'; local_20 = local_20 + 1) {
    bVar1 = do_fight(*local_20);
    sprintf(local_28,"%02X",(ulong)bVar1);
    local_28 = local_28 + 2;
  }
  *local_28 = '\0';
  return;
}

Here, the function will call another function do_fight to transform every byte of param_1 and then will store it to param_2 with hex encoding.

do_fight:

byte do_fight(byte param_1)

{
  byte local_1c;
  byte local_d;
  int local_c;
  
  local_d = 0;
  local_1c = param_1;
  for (local_c = 0; local_c < 8; local_c = local_c + 1) {
    local_d = local_d << 1 | local_1c & 1;
    local_1c = local_1c >> 1;
  }
  return local_d;
}

Finally the function do_fight will take a byte as input and then reverse the 8 bits. For example the character K: K => 75 in decimal which correspond to 01001011 IF we reverse these 8 bits, we get 01001011 => 11010010

Now, we know how it works, let’s getting our flag. From the main function, we saw the encoded flag is likely D2C22A62DEA62CCE9EFA0ECC86CE9AFA4ECC6EFAC6162C3636CC76E6A6BE We will use this and reverse the algorithm to retrieve a valid input.

This python script allows us to retrieve the flag :

def do_fight(byte):
    output=0
    for i in range(8):
        output = (output << 1) | (byte & 1)
        byte = byte >> 1
    return output

def reverse_transform_input(encoded_input):
    result=''
    for char in bytes.fromhex(encoded_input):
        decoded_char=do_fight(char)
        result += chr(decoded_char)
    return result

encoded_flag="D2C22A62DEA62CCE9EFA0ECC86CE9AFA4ECC6EFAC6162C3636CC76E6A6BE"
flag=reverse_transform_input(encoded_flag)
print(flag)

FLAG:KCTF{e4sy_p3asY_r3v_ch4ll3nge}


Knight’s Enigma Link to heading

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.

Flag Format : KCTF{s0mething_here}

Here, we are given an ELF binary named knight_s.enigma with PIE enabled.

You know the song, we will open it up with our disassembler, ghidra

The first thing we notice is that the main function is very long, containing over 1000 lines. The key to solving this challenge wasn’t to dig through all of that code, as it would be too long and tedious.

Instead, let’s focus on the interesting part.

  fgets(__s,0x80,stdin);
  sVar43 = strlen(__s);
  if (sVar43 != 0) {
    if (acStack_129[sVar43] == '\n') {
      acStack_129[sVar43] = '\0';
      sVar43 = sVar43 - 1;
    }
    if (sVar43 == 0x22) {
        ppuVar44 = __ctype_b_loc();
        puVar1 = *ppuVar44;
        lVar45 = 0;
        do {
            //biggest part of the program which transform the user input (~800 lines)
        }
        iVar142 = memcmp(local_a8,&local_158,0x22);
        if (iVar142 != 0) {
            puts("Congratulations knight!");
            goto LAB_0010114b;
        }
    }

Here’s the breakdown of what the program does:

  • It takes user input and stores it in __s.
  • It calculates the length of the user input.
  • First, it checks if the length of the input is greater than 0. If so, it will parse the input properly (purging \n).
  • Second, it checks if the user input has a length of 0x22, which corresponds to 34 characters.
  • Third, and most importantly, it compares two values: one is likely the user input, and the second one is probably the key.

The plan here is to dig into memcmp, and for this, we’ll be using GDB. But before we start debugging, we have some preparation to do.

First, we need to locate where memcmp is used in memory. By looking at the disassembler, we find it’s at address 0x00101d73, which will be used to set our breakpoint.

KCTF2025_Reverse_KnightEnigma1

Next, we generate the input that we’ll use during debugging execution. This is done using the following command: echo "012345678900112233445566778899abcd" > inputfile

Now we are ready to move into the debugging part : gdb ./knight_s.enigma

As i said earlier, here we are working on a PIE executable, which means every time we run the binary, it will get loaded into different memory address.

That being said, once in GDB i can use the command entry-break to set a breakpoint on the entry point of the program

gef➤  entry-break
[+] Breaking at entry-point: 0x555555555dc0

With this information, i know that during this specific execution, the entry point is at the address 0x555555555dc0

If we go back to ghidra, we notice the entry point is at address 0x00101dc0 KCTF2025_Reverse_KnightEnigma2

In this case, it’s obvious that the last 3 bits of the addresses are similar. Remember, memcmp() was located at x00101d73 on ghidra, so we can place our breakpoint to 0x555555555d73.

Note
  • entry-break is related go gef gdb extension
  • Memory addresses on your binary may differ from me.
  • If the last 3 bits are different between disassembler link ghidra and gdb, you can calculate the offset (0xd73 - 0xdc0 => offset of -77) and set a breakpoint: b * 0x555555555dc0 - 77, GDB will do math for you.

We set the breakpoint and run the program using following command:

b *0x555555555d73
run < inputfile

After some research about memcmp function, i found this article and learned that the memcmp function compares the $rsi and $rdi registers. Let’s take a look at their contents.

Here is the content of these registers:

gef➤  x/34xb $rdi
0x7fffffffdae0:	0xa6	0x26	0xe6	0x66	0x86	0x06	0xc6	0x46
0x7fffffffdae8:	0xb6	0x36	0xa6	0xa6	0x26	0x26	0xe6	0xe6
0x7fffffffdaf0:	0x66	0x66	0x86	0x86	0x06	0x06	0xc6	0xc6
0x7fffffffdaf8:	0x46	0x46	0xb6	0xb6	0x36	0x36	0xec	0x6c
0x7fffffffdb00:	0x8c	0x0c
gef➤  x/34xb $rsi
0x7fffffffda30:	0x98	0x88	0x00	0x48	0x74	0x50	0x8c	0xa6
0x7fffffffda38:	0x5c	0xbc	0x64	0xec	0x04	0x06	0x50	0x9c
0x7fffffffda40:	0x5c	0xfc	0xbc	0x3c	0x04	0x50	0xf4	0xa6
0x7fffffffda48:	0xc4	0x50	0xbc	0xa6	0x04	0x50	0x26	0x04
0x7fffffffda50:	0x50	0x14

By looking closer, we can recognize a pattern matching our user input in the RDI register. We can conclude that $rdi is related to the user input, and $rsi is probably the flag. The goal is to have these two registers match exactly in order to pass the memcmp() test.

The hex values stored in the RDI register are highly transformed and no longer seem related to their ASCII values. Without diving into all of these transformations, just by observing the register values, we can’t tell exactly how the user input is transformed.

With this in mind, we will proceed with a manual mapping of all the characters. Remember, the input we passed was “012345678900112233445566778899abcd”. By analyzing the content in the rdi register, we can see that ‘0’ is mapped to 0xa6, ‘1’ to 0x26, and so on.

However, numbers and a few lowercase characters alone won’t be enough; we need a larger alphabet. First, we do the mapping using “0123456789abcdefghijklmnopqrstuvwx”, and then continue with “yzABCDEFGHIJKLMNOPQRSTUVWXYZ{}_#()”. We need to do this in separate executions, as the program only accepts 34 characters as input. If we input more or fewer characters, it won’t run the part we want to dig into.

Once the mapping is done, we can associate the content retrieved in $rdi (which will never change) with the mapping we’ve created.

With all this information, we can retrieve the flag using the following Python script::

mapping = {
    0xa6: '0', 0x26: '1', 0xe6: '2', 0x66: '3',
    0x86: '4', 0x06: '5', 0xc6: '6', 0x46: '7',
    0xb6: '8', 0x36: '9', 0xec: 'a', 0x6c: 'b',
    0x8c: 'c', 0x0c: 'd', 0xcc: 'e', 0x4c: 'f',
    0xbc: 'g', 0x3c: 'h', 0xfc: 'i', 0x7c: 'j',
    0x9c: 'k', 0x1c: 'l', 0xdc: 'm', 0x5c: 'n',
    0xa4: 'o', 0x24: 'p', 0xe4: 'q', 0x64: 'r',
    0x84: 's', 0x04: 't', 0xc4: 'u', 0x44: 'v',
    0xb4: 'w', 0x34: 'x', 0xf4: 'y', 0x2c: 'z', 
    0xe8: 'A', 0x68: 'B', 0x88: 'C', 0x08: 'D',
    0xc8: 'E', 0x48: 'F', 0xb8: 'G', 0x38: 'H',
    0xf8: 'I', 0x78: 'J', 0x98: 'K', 0x18: 'L', 
    0xd8: 'M', 0x58: 'N', 0xa0: 'O', 0x20: 'P', 
    0xe0: 'Q', 0x60: 'R', 0x80: 'S', 0x00: 'T', 
    0xc0: 'U', 0x40: 'V', 0xb0: 'W', 0x30: 'X', 
    0xf0: 'Y', 0x28: 'Z', 0x74: '{', 0x14: '}', 
    0x50: '_', 0x6e: '#', 0xbe: '(', 0x3e: ')',
}

#rsi values
flag_values = [
    0x98, 0x88, 0x00, 0x48, 0x74, 0x50, 0x8c, 0xa6,
    0x5c, 0xbc, 0x64, 0xec, 0x04, 0x06, 0x50, 0x9c,
    0x5c, 0xfc, 0xbc, 0x3c, 0x04, 0x50, 0xf4, 0xa6,
    0xc4, 0x50, 0xbc, 0xa6, 0x04, 0x50, 0x26, 0x04,
    0x50, 0x14
]

flag = ''
for key in flag_values:
    flag += mapping[key]

print(flag)

FLAG: KCTF{c0ngrat5_knight_y0u_g0t_1t}

Worthy Knight Link to heading

The gates of the Crimson Keep stand locked, sealed by cryptic runes from ages past. Many challengers have tested their might against these ancient wards—yet all were found wanting. Will you speak the correct incantation and earn the Keep’s hidden treasures? Prove your valor and stand among legends… if you truly are a Worthy Knight.

Flag Format : KCTF{s0mething_here}

Same thing here, we open ghidra and see what is going on this binary.

  printf("Enter your incantation: ");
  pcVar5 = fgets(local_c8,0x80,stdin);
  if (pcVar5 == (char *)0x0) {
    puts("\nSomething went awry. Fare thee well...");
  }
  else {
    sVar6 = strcspn(local_c8,"\n");
    local_c8[sVar6] = 0;
    sVar6 = strlen(local_c8);
    if (sVar6 == 10) {
      ppuVar7 = __ctype_b_loc();
      pbVar8 = local_c8;
      do {
        uVar2 = (*ppuVar7)[*pbVar8];
        if (((uVar2 & 0x400) == 0) || (uVar3 = (*ppuVar7)[pbVar8[1]], (uVar3 & 0x400) == 0)) {
          puts("\nThe runes fail to align. The incantation is impure.");
          puts(&DAT_001022b8);
          goto LAB_0010124c;
        }
        if ((((uVar2 & 0x100) != 0) && ((uVar3 & 0x100) != 0)) ||
           (((uVar2 & 0x200) != 0 && ((uVar3 & 0x200) != 0)))) {
          puts("\nThe ancient seals do not resonate with your runes.");
          puts(&DAT_001022b8);
          goto LAB_00101460;
        }
        pbVar8 = pbVar8 + 2;
      } while (pbVar8 != abStack_be);

The program will take the user input and accept it to do its magic only if the user input has 10 characters and analyze 5 pair of 2 characters with following rules :

  • Do not contains any numerical characters , if its the case the program will print “The runes fail to align. The incantation is impure.” and terminate
  • Each pair is combination of lowercase and uppercase character (e.g: rE), f its the case the program will print “The ancient seals do not resonate with your runes.” and terminate

On the next steps , the program will check each pairs of characters

First pair:

if ((byte)(local_c8[1] ^ SUB161(_local_c8,0)) == 0x24) {
    if (local_c8[1] == 0x6a) {
        //Next step
    }
    else {
        puts("\nThe wards reject your Pair 1 second char.");
        puts(&DAT_001022b8);
    }
}
else {
puts("\nThe wards reject your Pair 1.");
puts(&DAT_001022b8);
}

pass[1] = 0x6a => 106 ‘j’

pass[0]: pass[1] ^ pass[0] = 36 => 106 ^ pass[0] = 36 => pass[0] = 106 ^ 36 = 78 ‘N’

Result: ‘Nj’

Second pair:

if ((local_c8[2] ^ local_c8[3]) == 0x38) {
    if (local_c8[3] == 0x53) {
        //Next step
    }
    else {
        puts("\nThe wards reject your Pair 2 second char.");
        puts(&DAT_001022b8);
    }
}
else {
puts("\nThe wards reject your Pair 2.");
puts(&DAT_001022b8);
}

pass[3] = 80 ‘S’ pass[2] = 83 ^ 56 = 107 ‘k’ Result: “kS”

Third pair:

iVar4 = strcmp(local_f8,"33a3192ba92b5a4803c9a9ed70ea5a9c");
if (iVar4 == 0) {
    //Next step
}

Here is a special case, instead of solving a xor equation, here we have a hash which is likely md5. we crack this using john:

echo "33a3192ba92b5a4803c9a9ed70ea5a9c" > hash.txt
john --format=raw-md5 hash.txt
john --format=raw-md5 hash.txt --show

The result of this crack is “Tf” but like other pair and we also have to reverse the characters Result: “fT”

Fourth pair:

if ((bStack_c2 ^ bStack_c1) == 0x38) {
    if (bStack_c1 == 0x61) {
        //Next step
    }
    else {
        puts("\nThe wards reject your Pair 4 second char.");
        puts(&DAT_001022b8);
    }
}
else {
puts("\nThe wards reject your Pair 4.");
puts(&DAT_001022b8);
}

pass[7] = 97 ‘a’ pass[6] = 97 ^ 56 = 89 ‘Y’ Result: “Ya”

Fifth pair:

if ((byte)(bStack_bf ^ SUB161(_local_c8,8)) == 0x20) {
    if (bStack_bf == 0x69) {
        //Next step : Win
    }
    else {
        puts("\nThe wards reject your Pair 5 second char.");
        puts(&DAT_001022b8);
    }
}
else {
puts("\nThe wards reject your Pair 5.");
puts(&DAT_001022b8);
}

pass[9] = 105 ‘i’ pass[8] = 105 ^ 32 = 73 ‘I’ Result: “Ii”

Final result : “NjkSfTYaIi”

We put the pass down in the program and win this :)

KCTF2025_Reverse_WorthyKnight1

FLAG: KCTF{NjkSfTYaIi}