PancakeCon CTF "Crack" Challenge

Jan. 17, 2022 // echel0n

Crack Write-Up

Hello guys! It's been a long time since wrote something here. Last Sunday, PancakesCon organized and I had an amazing time. Speakers, participants, the concept of the con and the topics of presentations and CTF were all wholesome! Thank you all for excellent work!

I wrote a challenge for the CTF which is called as "crack". In this blog, i will provide a write-up in details, hope you enjoy!

Intro

When we run the binary, it asks "the password" which is a traditional requirement of a crackme challenge.

  1. root@547dd0cda035:/shared# ./crack
  2. Can you provide the password?

Static Analysis

Generally speaking, for CTF RE challenges, i would prefer to run strings and ldd tools to see quickly what is waiting for me. ldd output is below;

  1. # ldd - print shared object dependencies
  2. root@547dd0cda035:/shared# ldd crack
  3. linux-vdso.so.1 (0x000067c12fdcb000)
  4. libcrypto.so.1.1 => /usr/lib/x86_64-linux-gnu/libcrypto.so.1.1 (0x000067c12fac4000)
  5. libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x000067c12f8ff000)
  6. libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x000067c12f8f9000)
  7. libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x000067c12f8d7000)
  8. /lib64/ld-linux-x86-64.so.2 (0x000067c12fdcd000)

With this output, i may think of "the password" is encrypted because of libcrypto library in-used. But, no worries. Binary uses libcrypto because of "base64" operations. I just wanted to make the payload easier to copy/paste to scripts. Therefore, it is better not to make assumptions before seeing the code.

Then i would prefer run strings command to see interesting strings. The interesting strings (for me) are below;

  1. QB1NJhYrQGYoQn88QFMSQGYtQH04Jhw3Jj0+JjUUJkctQmIhQr5VJiZIJihMJgdGQuFsQFw/JiREJgw2QoINQtJeQEgBQrtHQtJfQEMLJlkWQsVXQHkcQuVyQpQgJiM0QEwjQoMRJh1OQu1w
  2. HRZmf1NmfRw9NUdiviYoB+FcJAyC0ki70kNZxXnllCNMgx3t

They are looking like a base64 encoded payload but when we decode both base64 strings we see that they are not clear text.

  1. echo -ne "HRZmf1NmfRw9NUdiviYoB+FcJAyC0ki70kNZxXnllCNMgx3t" | base64 -d | xxd
  2. 00000000: 1d16 667f 5366 7d1c 3d35 4762 be26 2807 ..f.Sf}.=5Gb.&(.
  3. 00000010: e15c 240c 82d2 48bb d243 59c5 79e5 9423 .\$...H..CY.y..#
  4. 00000020: 4c83 1ded L...
  1. echo -ne "QB1NJhYrQGYoQn88QFMSQGYtQH04Jhw3Jj0+JjUUJkctQmIhQr5VJiZIJihMJgdGQuFsQFw/JiREJgw2QoINQtJeQEgBQrtHQtJfQEMLJlkWQsVXQHkcQuVyQpQgJiM0QEwjQoMRJh1OQu1w" | base64 -d | xxd
  2. 00000000: 401d 4d26 162b 4066 2842 7f3c 4053 1240 @.M&.+@f(B.<@S.@
  3. 00000010: 662d 407d 3826 1c37 263d 3e26 3514 2647 f-@}8&.7&=>&5.&G
  4. 00000020: 2d42 6221 42be 5526 2648 2628 4c26 0746 -Bb!B.U&&H&(L&.F
  5. 00000030: 42e1 6c40 5c3f 2624 4426 0c36 4282 0d42 B.l@\?&$D&.6B..B
  6. 00000040: d25e 4048 0142 bb47 42d2 5f40 430b 2659 .^@H.B.GB._@C.&Y
  7. 00000050: 1642 c557 4079 1c42 e572 4294 2026 2334 .B.W@y.B.rB. &#4
  8. 00000060: 404c 2342 8311 261d 4e42 ed70 @L#B..&.NB.p

The longer payload looks like it has repeating characters.  Lets try to decompile with ghidra to see what is all about!

 

TLDR notes from main function;

  1. It expects the given password's len 36.
  2. The found both base64 encoded strings are going into a function that starts at (base + 00001453)
  3. After password is provided, main calls the function that starts at (base + 00001575)

(base + 00001453) function

With the help of internet, it can be stated as it is a common way to decode a base64 string with openssl library. We can pass this function.

(base + 00001575) function

TLDR notes;

  1. The function's first argument(arg_1) is raw payload of the base64 string that starts with "QB1N"
  2. The function's second argument(arg_2) is the string that player provided.
  3. The loop goes on while var_18h is lesser than 107, the exact length of raw payload of the base64 encoded string that starts with "QB1N"
  4. arg_1 + var_18h, arg_2 + var_20h._4_4_ & 0xff and arg_1 + var_18h + 2 & 0xff values are the given to the function that starts at (base + 0x1531) and also uVar3 is the return value of that function.
  5. uVar3 and cVar1 must be equal, otherwise the the boolean variable becomes true, then function knows that comparison is failed. the code is below;
    1. ...
    2. ...
    3. if (uVar3 != ((int32_t)cVar1 & 0xffU)) {
    4. bVar2 = true;
    5. goto code_r0x0000166f;
    6. }
    7. ...
  6. Every step of the loop var_20h._4_4_increases by 1
  7. Every step of the loop var_18h increases by 3

With the knowledge that above, we can see that the function parses the raw payload three bytes at once. var_18h is the step. Remember the length of flag it is 36 (108/3=36).

Let's do an example!

  1. The payload first three bytes 0x40, 0x1d, 0x4d ;
  2. uVar3 = func_1531(0x40, USER_PROVIDED_PASSWORD[0], 0x4d)
  3. cVar1 = 0x1d (The correct result of func_1531)
  4. NOTE: 0x1d byte is the first byte of other base64 string,
  5. I just put it there to make it easier.

So, what func_1531() does? Let's go and find out!

(base + 00001531) function


It is a very clear and direct function. It has three if statements. In short, first argument is the condition that decides which operation will be used. The operations listed below;

  1. If arg_1 is 0x42 then it is ADD instruction
  2. If arg_1 is 0x26 then it is SUB instruction
  3. If arg_1 is 0x40 then it is XOR instruction

Let's do an example!

  1. # The payload first three bytes 0x40, 0x1d, 0x4d ;
  2. arg_1 = 0x40
  3. arg_2 = 0x61 # (which is 'a', user provided byte but the valid byte is 0x1d)
  4. arg_3 = 0x4d
  5. # this can be read as "XOR 0x61, 0x4d"

That means, for the first iteration, we have to revert XOR operation. To do this, simply we can XOR again "0x1d" with the arg_3;

  1. hex(0x1d ^ 0x4d) = '0x50' # which is chr(0x50) == "P"

Clearing things

We found and analyzed the binary's string operations strategy. From here, it is pretty much clear to what to do with remaining 35 characters. We can do it in a python script to get these characters quickly.

  1. CALL_ADD = b"\x42"
  2. CALL_SUB = b"\x26"
  3. CALL_XOR = b"\x40"
  4. def echelonvm_to_humaneyes(call_n, reg_1, reg_2):
  5. call_n = call_n.to_bytes(1, "little")
  6. if reg_1 > 0xFF or reg_2 > 0xFF:
  7. return -1
  8. if call_n == CALL_ADD:
  9. return f"ADD {reg_1} {reg_2}"
  10. elif call_n == CALL_SUB:
  11. return f"SUB {reg_1} {reg_2}"
  12. elif call_n == CALL_XOR:
  13. return f"XOR {reg_1} {reg_2}"
  14. def echelon_num_machine_revert(call_num, reg_1, reg_2):
  15. call_num = call_num.to_bytes(1, "little")
  16. if reg_1 > 0xFF or reg_2 > 0xFF:
  17. return -1
  18. if call_num == CALL_ADD:
  19. return reg_1 - reg_2
  20. elif call_num == CALL_SUB:
  21. return reg_1 + reg_2
  22. elif call_num == CALL_XOR:
  23. return reg_1 ^ reg_2
  24. def echelonvm_solver():
  25. stack = []
  26. echelonvm_f = open("./echelonvm_code", "rb") # base64 decoded QB1N.. string
  27. content = echelonvm_f.read()
  28. real_password = ""
  29. # call, reg_1, reg_2
  30. for i in range(0, len(content), 3):
  31. call = content[i]
  32. reg_1 = content[i + 1]
  33. reg_2 = content[i + 2]
  34. output = echelon_num_machine_revert(call, reg_1, reg_2)
  35. print(
  36. echelonvm_to_humaneyes(int(call), int(reg_1), int(reg_2)),
  37. f" = {output}",
  38. )
  39. real_password += chr(output)
  40. print(f"Password is: {real_password}")
The output is:
  1. $ python solver.py
  2. XOR 29 77 = 80
  3. SUB 22 43 = 65
  4. XOR 102 40 = 78
  5. ADD 127 60 = 67
  6. XOR 83 18 = 65
  7. XOR 102 45 = 75
  8. XOR 125 56 = 69
  9. SUB 28 55 = 83
  10. SUB 61 62 = 123
  11. SUB 53 20 = 73
  12. SUB 71 45 = 116
  13. ADD 98 33 = 65
  14. ADD 190 85 = 105
  15. SUB 38 72 = 110
  16. SUB 40 76 = 116
  17. SUB 7 70 = 77
  18. ADD 225 108 = 117
  19. XOR 92 63 = 99
  20. SUB 36 68 = 104
  21. SUB 12 54 = 66
  22. ADD 130 13 = 117
  23. ADD 210 94 = 116
  24. XOR 72 1 = 73
  25. ADD 187 71 = 116
  26. ADD 210 95 = 115
  27. XOR 67 11 = 72
  28. SUB 89 22 = 111
  29. ADD 197 87 = 110
  30. XOR 121 28 = 101
  31. ADD 229 114 = 115
  32. ADD 148 32 = 116
  33. SUB 35 52 = 87
  34. XOR 76 35 = 111
  35. ADD 131 17 = 114
  36. SUB 29 78 = 107
  37. ADD 237 112 = 125
  38. Password is: PANCAKES{ItAintMuchButItsHonestWork}

Bonus Qiling Solution

We can do this conversion in func_1531() with Qiling Framework. Qiling Framework is an advanced binary emulation framework, built on top of unicorn. With help of emulation, we can change the user provided characters to valid characters on the fly. We can simply hook func_153() function to see what's happening and hot patch register values to reveal the valid password with the information based on our static analysis.

Let's start!

  1. from qiling import *
  2. from qiling.const import *
  3. import base64
  4. lookup_table = {0x42: "ADD", 0x26: "SUB", 0x40: "XOR"} # valid byte operations
  5. encrypted_password = base64.b64decode(
  6. "HRZmf1NmfRw9NUdiviYoB+FcJAyC0ki70kNZxXnllCNMgx3t"
  7. ) # valid password
  8. index = 0 # will be used for knowing which index we are processing
  9. buildstr = "" # the valid characters will be put in this variable.
  10. # this function reverts the operation and returns the valid byte
  11. def compare_values(inst, reg_2):
  12. global index
  13. ret: int
  14. real_value = encrypted_password[index]
  15. if inst == "ADD": # revert the operation
  16. ret = real_value - reg_2
  17. return ret
  18. elif inst == "SUB": # revert the operation
  19. ret = real_value + reg_2
  20. return ret
  21. elif inst == "XOR": # simply xor again to get the value.
  22. ret = real_value ^ reg_2
  23. return ret
  24. # this is the function that will hook when the function is called
  25. def hook_interpreter(ql):
  26. global index
  27. global buildstr
  28. """
  29. fcn.00001531 (uint64_t arg1, int64_t arg2, int64_t arg3);
  30. ; var int64_t var_ch @ rbp-0xc
  31. ; var int64_t var_8h @ rbp-0x8
  32. ; var uint64_t var_4h @ rbp-0x4
  33. ; arg uint64_t arg1 @ rdi
  34. ; arg int64_t arg2 @ rsi
  35. ; arg int64_t arg3 @ rdx
  36. 0x00001539 mov dword [var_4h], edi ; arg1
  37. 0x0000153c mov dword [var_8h], esi ; arg2
  38. 0x0000153f mov dword [var_ch], edx ; arg3
  39. 0x00001542 cmp dword [var_4h], 0x42
  40. var_4h: instruction
  41. var_8h: reg_1
  42. var_ch: reg_2
  43. if instruction == \\x42, it is ADD reg_1, reg_2
  44. if instruction == \\x26, it is SUB reg_1, reg_2
  45. if instruction == \\x40, it is XOR reg_1, reg_2
  46. """
  47. instruction = ql.reg.rdi # it holds the operation. Valid operations are ADD, SUB or XOR
  48. reg_1 = ql.reg.rsi # User input, we will change this register to valid char
  49. reg_2 = ql.reg.rdx # the second value to compute
  50. real_value = chr(compare_values(lookup_table[instruction], reg_2))
  51. print(f"QILING:{lookup_table[instruction]} {hex(reg_1)}, {hex(reg_2)}")
  52. print(
  53. f"QILING: You provided {chr(reg_1)} ---- the real value is {real_value}, i changed it anyway."
  54. )
  55. # changing the register that holds our provided char to valid char
  56. ql.reg.rsi = ord(real_value)
  57. # build flag string for printing in the end
  58. index += 1 # used for comparison
  59. buildstr += real_value
  60. def prepare():
  61. ql = Qiling(
  62. [
  63. "crack",
  64. ],
  65. rootfs="rootfs/",
  66. verbose=QL_VERBOSE.OFF,
  67. multithread=True,
  68. )
  69. return ql
  70. def main():
  71. global buildstr
  72. ql = prepare()
  73. # get base addr
  74. base_addr = ql.mem.get_lib_base(ql.path)
  75. # it is the address of interpreter function
  76. ql.hook_address(hook_interpreter, base_addr + 0x1531)
  77. ql.run()
  78. print(f"Emulation ended. Flag is: {buildstr}")
  79. if __name__ == "__main__":
  80. main()

The result;

Thank you for reading my write-up! Have a nice day absolute legends!