[MENU] | |||||||||
[THOUGHTS] | [TECH RESOURCES] | [TRASH TALK] | |||||||
[DANK MEMES] | [FEATURED ARTISTS] | [W] |
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!
When we run the binary, it asks "the password" which is a traditional requirement of a crackme challenge.
- root@547dd0cda035:/shared# ./crack
- Can you provide the password?
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;
- # ldd - print shared object dependencies
-
- root@547dd0cda035:/shared# ldd crack
- linux-vdso.so.1 (0x000067c12fdcb000)
- libcrypto.so.1.1 => /usr/lib/x86_64-linux-gnu/libcrypto.so.1.1 (0x000067c12fac4000)
- libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x000067c12f8ff000)
- libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x000067c12f8f9000)
- libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x000067c12f8d7000)
- /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;
- QB1NJhYrQGYoQn88QFMSQGYtQH04Jhw3Jj0+JjUUJkctQmIhQr5VJiZIJihMJgdGQuFsQFw/JiREJgw2QoINQtJeQEgBQrtHQtJfQEMLJlkWQsVXQHkcQuVyQpQgJiM0QEwjQoMRJh1OQu1w
-
- 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.
- echo -ne "HRZmf1NmfRw9NUdiviYoB+FcJAyC0ki70kNZxXnllCNMgx3t" | base64 -d | xxd
- 00000000: 1d16 667f 5366 7d1c 3d35 4762 be26 2807 ..f.Sf}.=5Gb.&(.
- 00000010: e15c 240c 82d2 48bb d243 59c5 79e5 9423 .\$...H..CY.y..#
- 00000020: 4c83 1ded L...
- echo -ne "QB1NJhYrQGYoQn88QFMSQGYtQH04Jhw3Jj0+JjUUJkctQmIhQr5VJiZIJihMJgdGQuFsQFw/JiREJgw2QoINQtJeQEgBQrtHQtJfQEMLJlkWQsVXQHkcQuVyQpQgJiM0QEwjQoMRJh1OQu1w" | base64 -d | xxd
- 00000000: 401d 4d26 162b 4066 2842 7f3c 4053 1240 @.M&.+@f(B.<@S.@
- 00000010: 662d 407d 3826 1c37 263d 3e26 3514 2647 f-@}8&.7&=>&5.&G
- 00000020: 2d42 6221 42be 5526 2648 2628 4c26 0746 -Bb!B.U&&H&(L&.F
- 00000030: 42e1 6c40 5c3f 2624 4426 0c36 4282 0d42 B.l@\?&$D&.6B..B
- 00000040: d25e 4048 0142 bb47 42d2 5f40 430b 2659 .^@H.B.GB._@C.&Y
- 00000050: 1642 c557 4079 1c42 e572 4294 2026 2334 .B.W@y.B.rB. 
- 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;
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.
TLDR notes;
- ...
- ...
- if (uVar3 != ((int32_t)cVar1 & 0xffU)) {
- bVar2 = true;
- goto code_r0x0000166f;
- }
- ...
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!
- The payload first three bytes 0x40, 0x1d, 0x4d ;
- uVar3 = func_1531(0x40, USER_PROVIDED_PASSWORD[0], 0x4d)
- cVar1 = 0x1d (The correct result of func_1531)
-
- NOTE: 0x1d byte is the first byte of other base64 string,
- I just put it there to make it easier.
So, what func_1531() does? Let's go and find out!
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;
Let's do an example!
- # The payload first three bytes 0x40, 0x1d, 0x4d ;
- arg_1 = 0x40
- arg_2 = 0x61 # (which is 'a', user provided byte but the valid byte is 0x1d)
- arg_3 = 0x4d
- # 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;
- hex(0x1d ^ 0x4d) = '0x50' # which is chr(0x50) == "P"
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.
- CALL_ADD = b"\x42"
- CALL_SUB = b"\x26"
- CALL_XOR = b"\x40"
-
- def echelonvm_to_humaneyes(call_n, reg_1, reg_2):
- call_n = call_n.to_bytes(1, "little")
- if reg_1 > 0xFF or reg_2 > 0xFF:
- return -1
- if call_n == CALL_ADD:
- return f"ADD {reg_1} {reg_2}"
- elif call_n == CALL_SUB:
- return f"SUB {reg_1} {reg_2}"
- elif call_n == CALL_XOR:
- return f"XOR {reg_1} {reg_2}"
-
-
- def echelon_num_machine_revert(call_num, reg_1, reg_2):
- call_num = call_num.to_bytes(1, "little")
- if reg_1 > 0xFF or reg_2 > 0xFF:
- return -1
- if call_num == CALL_ADD:
- return reg_1 - reg_2
- elif call_num == CALL_SUB:
- return reg_1 + reg_2
- elif call_num == CALL_XOR:
- return reg_1 ^ reg_2
-
- def echelonvm_solver():
- stack = []
- echelonvm_f = open("./echelonvm_code", "rb") # base64 decoded QB1N.. string
- content = echelonvm_f.read()
- real_password = ""
- # call, reg_1, reg_2
- for i in range(0, len(content), 3):
- call = content[i]
- reg_1 = content[i + 1]
- reg_2 = content[i + 2]
- output = echelon_num_machine_revert(call, reg_1, reg_2)
- print(
- echelonvm_to_humaneyes(int(call), int(reg_1), int(reg_2)),
- f" = {output}",
- )
- real_password += chr(output)
-
- print(f"Password is: {real_password}")
- $ python solver.py
- XOR 29 77 = 80
- SUB 22 43 = 65
- XOR 102 40 = 78
- ADD 127 60 = 67
- XOR 83 18 = 65
- XOR 102 45 = 75
- XOR 125 56 = 69
- SUB 28 55 = 83
- SUB 61 62 = 123
- SUB 53 20 = 73
- SUB 71 45 = 116
- ADD 98 33 = 65
- ADD 190 85 = 105
- SUB 38 72 = 110
- SUB 40 76 = 116
- SUB 7 70 = 77
- ADD 225 108 = 117
- XOR 92 63 = 99
- SUB 36 68 = 104
- SUB 12 54 = 66
- ADD 130 13 = 117
- ADD 210 94 = 116
- XOR 72 1 = 73
- ADD 187 71 = 116
- ADD 210 95 = 115
- XOR 67 11 = 72
- SUB 89 22 = 111
- ADD 197 87 = 110
- XOR 121 28 = 101
- ADD 229 114 = 115
- ADD 148 32 = 116
- SUB 35 52 = 87
- XOR 76 35 = 111
- ADD 131 17 = 114
- SUB 29 78 = 107
- ADD 237 112 = 125
- Password is: PANCAKES{ItAintMuchButItsHonestWork}
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!
- from qiling import *
- from qiling.const import *
- import base64
-
- lookup_table = {0x42: "ADD", 0x26: "SUB", 0x40: "XOR"} # valid byte operations
-
- encrypted_password = base64.b64decode(
- "HRZmf1NmfRw9NUdiviYoB+FcJAyC0ki70kNZxXnllCNMgx3t"
- ) # valid password
- index = 0 # will be used for knowing which index we are processing
- buildstr = "" # the valid characters will be put in this variable.
-
- # this function reverts the operation and returns the valid byte
- def compare_values(inst, reg_2):
- global index
- ret: int
- real_value = encrypted_password[index]
- if inst == "ADD": # revert the operation
- ret = real_value - reg_2
- return ret
- elif inst == "SUB": # revert the operation
- ret = real_value + reg_2
- return ret
- elif inst == "XOR": # simply xor again to get the value.
- ret = real_value ^ reg_2
- return ret
-
- # this is the function that will hook when the function is called
- def hook_interpreter(ql):
- global index
- global buildstr
- """
- fcn.00001531 (uint64_t arg1, int64_t arg2, int64_t arg3);
- ; var int64_t var_ch @ rbp-0xc
- ; var int64_t var_8h @ rbp-0x8
- ; var uint64_t var_4h @ rbp-0x4
- ; arg uint64_t arg1 @ rdi
- ; arg int64_t arg2 @ rsi
- ; arg int64_t arg3 @ rdx
- 0x00001539 mov dword [var_4h], edi ; arg1
- 0x0000153c mov dword [var_8h], esi ; arg2
- 0x0000153f mov dword [var_ch], edx ; arg3
- 0x00001542 cmp dword [var_4h], 0x42
- var_4h: instruction
- var_8h: reg_1
- var_ch: reg_2
- if instruction == \\x42, it is ADD reg_1, reg_2
- if instruction == \\x26, it is SUB reg_1, reg_2
- if instruction == \\x40, it is XOR reg_1, reg_2
- """
-
- instruction = ql.reg.rdi # it holds the operation. Valid operations are ADD, SUB or XOR
- reg_1 = ql.reg.rsi # User input, we will change this register to valid char
- reg_2 = ql.reg.rdx # the second value to compute
-
- real_value = chr(compare_values(lookup_table[instruction], reg_2))
-
- print(f"QILING:{lookup_table[instruction]} {hex(reg_1)}, {hex(reg_2)}")
- print(
- f"QILING: You provided {chr(reg_1)} ---- the real value is {real_value}, i changed it anyway."
- )
-
- # changing the register that holds our provided char to valid char
- ql.reg.rsi = ord(real_value)
-
- # build flag string for printing in the end
- index += 1 # used for comparison
- buildstr += real_value
-
- def prepare():
- ql = Qiling(
- [
- "crack",
- ],
- rootfs="rootfs/",
- verbose=QL_VERBOSE.OFF,
- multithread=True,
- )
- return ql
-
- def main():
- global buildstr
- ql = prepare()
-
- # get base addr
- base_addr = ql.mem.get_lib_base(ql.path)
-
- # it is the address of interpreter function
- ql.hook_address(hook_interpreter, base_addr + 0x1531)
- ql.run()
-
- print(f"Emulation ended. Flag is: {buildstr}")
-
-
- if __name__ == "__main__":
- main()
The result;