Description
Analysis
We start by identifying the file type and examining its basic properties:
$ file angry_patched_skill_issues
angry_patched_skill_issues: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=07f247f
16553ce6c1b43a069b4158cc676079e87, for GNU/Linux 3.2.0, stripped
This tells us the executable is a dynamically linked ELF binary for 64-bit x86 processors running on a Linux system. The “stripped” designation means the debugging symbols have been removed, making reverse engineering more challenging.
We use Ghidra to decompile the main function and examine its behavior:
undefined8 main(void) {
int is_correct;
char *buffer;
buffer = (char *)malloc(1);
custom_read(buffer,"Give me a password : ",0x28);
is_correct = first_check(buffer);
if (is_correct == 0) {
puts("Bruh : (");
}
else {
is_correct = second_check(buffer);
if (is_correct == 0) {
puts("Bruh : (");
}
else {
puts("Congratulations !");
}
}
return 0;
}
The main function is straightforward. It prompts the user for a password, stores it in a buffer, and performs two checks: first_check
and second_check
. If both checks pass, the program prints “Congratulations!”, otherwise it displays “Bruh : (“.
The first_check
function is much more complex. It performs a series of byte-based comparisons and logical operations on the password buffer.
undefined8 first_check(byte *buffer) {
int flag_length;
size_t buffer_length;
undefined8 is_correct;
buffer_length = strlen((char *)buffer);
flag_length = get_length();
if (((((((((buffer_length == (long)flag_length) &&
((int)(char)buffer[0xf] * (int)(char)buffer[5] - (int)(char)buffer[7] == 0x12ec)) &&
(buffer[6] == 0x6e)) &&
(((int)(char)buffer[7] * (int)(char)buffer[7] == 0x2971 && (buffer[8] != 0)))) &&
((buffer[9] != 0 &&
((((int)(char)buffer[10] & 0x3fffffffU) == 0x34 && (buffer[0xb] == 0x5f)))))) &&
(buffer[0xc] == 0x6c)) &&
((((((int)(char)(buffer[2] ^ buffer[0xd] ^ *buffer ^ buffer[1]) != (uint)(buffer[3] == 0x44)
&& ((int)(char)buffer[0x11] +
(int)(char)buffer[0xe] + (int)(char)buffer[0xf] + (int)(char)buffer[0x10] == 0x15c))
&& ((int)(char)(buffer[1] ^ buffer[0xf]) != (uint)(buffer[2] == 0x41))) &&
(((((int)(char)buffer[0x10] + (int)(char)buffer[0x15]) - (int)(char)buffer[0x19] == 0x55 &&
((int)(char)buffer[0x21] + ((int)(char)buffer[0x11] - (int)(char)buffer[0x20]) == 0x9c))
&& ((buffer[0x12] == 0x30 && ((buffer[0x13] == 0x6e && (buffer[0x14] == 0x74)))))))) &&
(((buffer[0x11] ^ buffer[0x15]) == 0x3b &&
(((buffer[0x16] == 100 && ((int)(char)buffer[0x17] != (uint)(buffer[0x15] == 10))) &&
(buffer[0x18] == 0x5f)))))))) &&
(((((int)(char)buffer[0x19] - (int)(char)buffer[5] == 8 && (buffer[0x1a] != 1)) &&
(((buffer[0x1b] == 0x5f &&
((((int)(char)buffer[0x1c] + (int)(char)buffer[0x15]) - (int)(char)buffer[0x19] == 99 &&
((int)(char)buffer[0x1d] !=
(uint)((int)(char)buffer[0x1c] + (int)(char)buffer[0x1f] == 0xf6))))) &&
(buffer[0x1e] == 0x6e)))) &&
(((((int)(char)buffer[0x1f] != (uint)(buffer[0xd] == 100) &&
((int)(char)buffer[0x1f] + (int)(char)buffer[0x22] == 0xc1)) &&
((int)(char)buffer[0x21] + (int)(char)buffer[0x20] == 0xa0)) &&
((buffer[0x21] == 0x6c && ((int)(char)buffer[0x21] - (int)(char)buffer[1] == 0x39)))))))) &&
((buffer[0x22] == 0x6c && ((buffer[0x23] == 0x79 && (buffer[0x24] == 0x7d)))))) {
is_correct = 1;
}
else {
is_correct = 0;
}
return is_correct;
}
Here’s a graph representation of the previous function, it looks really ugly :]
The second_check
function is relatively simpler, checking specific bytes in the password against known values.
undefined8 second_check(char *buffer) {
undefined8 is_correct;
if ((((buffer[8] == 'r') && (buffer[9] == '_')) && (buffer[0x17] == 'o')) &&
((buffer[0x1d] == '4' && ((char)(buffer[10] & 0xcfU) < '\n')))) {
is_correct = 1;
}
else {
is_correct = 0;
}
return is_correct;
}
Solution
As we saw in the analysis, we can obviously solve this manually by looking at each condition and trying to figure out what satisfies it, in the end we’ll get the correct password, which at this point I guess is the flag, but that’s too boring and takes too long, instead we’re going to use angr
to automatically solve this, but before we could do that, we’ll need first to get some addresses and to learn more about the structure of the program, the binary is stripped so let’s find the entry point using pwndbg
:
pwndbg> info file
Symbols from "./angry_patched_skill_issues".
Local exec file:
`./angry_patched_skill_issues', file type elf64-x86-64.
Entry point: 0x10e0
0x0000000000000318 - 0x0000000000000334 is .interp
0x0000000000000338 - 0x0000000000000368 is .note.gnu.property
[...]
The entry point is at 0x10e0
, using this we can find the main
function:
pwndbg> x/32i 0x10e0
0x10e0: endbr64
0x10e4: xor ebp,ebp
0x10e6: mov r9,rdx
0x10e9: pop rsi
0x10ea: mov rdx,rsp
0x10ed: and rsp,0xfffffffffffffff0
0x10f1: push rax
0x10f2: push rsp
0x10f3: xor r8d,r8d
0x10f6: xor ecx,ecx
0x10f8: lea rdi,[rip+0x6c8] # 0x17c7 <====== main address
0x10ff: call QWORD PTR [rip+0x2ed3] # 0x3fd8
0x1105: hlt
0x1106: cs nop WORD PTR [rax+rax*1+0x0]
0x1110: lea rdi,[rip+0x2ef9] # 0x4010 <stdin>
0x1117: lea rax,[rip+0x2ef2] # 0x4010 <stdin>
0x111e: cmp rax,rdi
0x1121: je 0x1138
0x1123: mov rax,QWORD PTR [rip+0x2eb6] # 0x3fe0
0x112a: test rax,rax
0x112d: je 0x1138
0x112f: jmp rax
0x1131: nop DWORD PTR [rax+0x0]
0x1138: ret
0x1139: nop DWORD PTR [rax+0x0]
0x1140: lea rdi,[rip+0x2ec9] # 0x4010 <stdin>
0x1147: lea rsi,[rip+0x2ec2] # 0x4010 <stdin>
0x114e: sub rsi,rdi
0x1151: mov rax,rsi
0x1154: shr rsi,0x3f
0x1158: sar rax,0x3
0x115c: add rsi,rax
pwndbg>
Looking at the disassembly, we can see that the main
function is at 0x17c7
pwndbg> x/32i 0x17c7
0x17c7: endbr64
0x17cb: push rbp
0x17cc: mov rbp,rsp
0x17cf: sub rsp,0x10
0x17d3: mov edi,0x1
0x17d8: call 0x10d0 <malloc@plt>
0x17dd: mov QWORD PTR [rbp-0x8],rax
0x17e1: mov rax,QWORD PTR [rbp-0x8]
0x17e5: mov edx,0x28
0x17ea: lea rcx,[rip+0x816] # 0x2007
0x17f1: mov rsi,rcx
0x17f4: mov rdi,rax
0x17f7: call 0x176e
0x17fc: mov rax,QWORD PTR [rbp-0x8]
0x1800: mov rdi,rax
0x1803: call 0x1275
0x1808: test eax,eax
0x180a: je 0x183e
0x180c: mov rax,QWORD PTR [rbp-0x8]
0x1810: mov rdi,rax
0x1813: call 0x120d
[...]
pwndbg>
We know that the first_check
function is called first after the custom_read
, so from the above output, the first_check
is at 0x1275
, the second function that is being called is the second_check
function so it’s address is 0x120d
.
Below is the output of disassembling the first_check
function, we’re interested in the comparisons that are used to verify the flag, which start at address 0x12aa
, and as we can see that when the check is wrong it always jumps to address 0x1725
.
pwndbg> x/32i 0x1275
0x1275: endbr64
0x1279: push rbp
0x127a: mov rbp,rsp
0x127d: push rbx
0x127e: sub rsp,0x18
0x1282: mov QWORD PTR [rbp-0x18],rdi
0x1286: mov rax,QWORD PTR [rbp-0x18]
0x128a: mov rdi,rax
0x128d: call 0x10a0 <strlen@plt>
0x1292: mov rbx,rax
0x1295: mov eax,0x0
0x129a: call 0x11fe
0x129f: cdqe
0x12a1: cmp rbx,rax
0x12a4: jne 0x1725
0x12aa: mov rax,QWORD PTR [rbp-0x18]
0x12ae: add rax,0x5
0x12b2: movzx eax,BYTE PTR [rax]
0x12b5: movsx edx,al
0x12b8: mov rax,QWORD PTR [rbp-0x18]
0x12bc: add rax,0xf
0x12c0: movzx eax,BYTE PTR [rax]
0x12c3: movsx eax,al
0x12c6: imul eax,edx
0x12c9: mov rdx,QWORD PTR [rbp-0x18]
0x12cd: add rdx,0x7
0x12d1: movzx edx,BYTE PTR [rdx]
0x12d4: movsx edx,dl
0x12d7: sub eax,edx
0x12d9: cmp eax,0x12ec
0x12de: jne 0x1725
0x12e4: mov rax,QWORD PTR [rbp-0x18]
Here’s a snippet from the end of the function first_check
, as we can see the previous address that we found was 0x1725
, the instructions in this address put 0 into eax
which is the return value of the function, so what we want is to reach 0x171e
which means that the all checks are valid (the function returns 1).
0x171c: jne 0x1725
0x171e: mov eax,0x1
0x1723: jmp 0x172a
0x1725: mov eax,0x0
0x172a: mov rbx,QWORD PTR [rbp-0x8]
0x172e: leave
0x172f: ret
Now let’s disassemble the second_check
function, same as previous function, the program jumps to 0x126e
when the password is incorrect, and to 0x1267
when all the checks are valid.
pwndbg> x/35i 0x120d
0x120d: endbr64
0x1211: push rbp
0x1212: mov rbp,rsp
0x1215: mov QWORD PTR [rbp-0x8],rdi
0x1219: mov rax,QWORD PTR [rbp-0x8]
0x121d: add rax,0x8
0x1221: movzx eax,BYTE PTR [rax]
0x1224: cmp al,0x72
0x1226: jne 0x126e
0x1228: mov rax,QWORD PTR [rbp-0x8]
0x122c: add rax,0x9
0x1230: movzx eax,BYTE PTR [rax]
0x1233: cmp al,0x5f
0x1235: jne 0x126e
0x1237: mov rax,QWORD PTR [rbp-0x8]
0x123b: add rax,0x17
0x123f: movzx eax,BYTE PTR [rax]
0x1242: cmp al,0x6f
0x1244: jne 0x126e
0x1246: mov rax,QWORD PTR [rbp-0x8]
0x124a: add rax,0x1d
0x124e: movzx eax,BYTE PTR [rax]
0x1251: cmp al,0x34
0x1253: jne 0x126e
0x1255: mov rax,QWORD PTR [rbp-0x8]
0x1259: add rax,0xa
0x125d: movzx eax,BYTE PTR [rax]
0x1260: and eax,0xffffffcf
0x1263: cmp al,0x9
0x1265: jg 0x126e
0x1267: mov eax,0x1
0x126c: jmp 0x1273
0x126e: mov eax,0x0
0x1273: pop rbp
0x1274: ret
So as a recap here’s what we got now:
- The flag length is
0x25
- The address of the first check’s conditions
0x12aa
, - The address that the program goes to when the password is wrong in the first check
0x1725
- The address that the program goes to when the password is correct in the first check
0x171e
- The address of the second check’s conditions
0x121d
- The address that the program goes to when the password is wrong in the second check
0x126e
- The address that the program goes to when the password is correct in the first check
0x1267
So now, let’s use angr
to symbolically execute the binary and see if we could find the password, let’s import the libraries and load a new project, as we’re using addresses from base zero, let’s set the base address to be zero and the entry point of this simulation to be the address of the first conditions:
import angr
import claripy
p = angr.Project('./angry_patched_skill_issues', main_opts={'base_addr': 0}, auto_load_libs=False)
initial_state = p.factory.blank_state(addr=0x12aa, add_options={angr.options.LAZY_SOLVES})
As we know the paths that the program takes when the password is correct or wrong, let’s store them in variables:
good_address = 0x171e
bad_address = 0x1725
We learned from the analysis that the flags length is 0x25
so let’s define a symbolic vector that’ll represent the flag in our simulation, and give it a random address:
flag = claripy.BVS('flag', 0x25*8)
flag_address = 0x44444444
After that let’s copy the initial_state of the simulation, and because we’re not starting from the beginning of the function, let’s setup the registers with the correct values, and let’s setup the stack too:
state = initial_state.copy()
state.regs.rax = 0
state.regs.rbp = state.regs.rsp
state.regs.rbx = 0x25
state.regs.rsp -= 0x18
Then, let’s store the flag’s symbolic vector in memory, and put the flag address in the stack:
state.memory.store(flag_address, flag)
state.memory.store(state.regs.rbp - 0x18, flag_address, endness='Iend_LE', size=64)
Let’s start the simulation:
simulation = p.factory.simgr(state)
simulation.explore(find=good_address, avoid=bad_address)
If something is found, we’ll have to pass to the second check:
if len(simulation.found):
solution_state = simulation.found[0]
first_check = solution_state.solver.eval(flag, cast_to=bytes)
print(f'First check results: {first_check}')
We’ll just do the same thing as the first check, we’ll have to setup the registers for it, and change the good_address
and bad_address
, we’ll copy the previous solution state to conserve the flag’s vector and memory, then we’ll start the simulation:
good_address = 0x1267
bad_address = 0x126e
state = solution_state
state.regs.rip = 0x121d
state.regs.cc_ndep = 0x99999999 # just to get rid of annoying angr warnings
state.regs.rbp = state.regs.rsp
state.memory.store(state.regs.rbp - 0x8, flag_address, endness='Iend_LE', size=64)
state.memory.store(flag_address, flag)
state.regs.rax = flag_address
simulation = p.factory.simgr(state)
simulation.explore(find=good_address, avoid=bad_address)
We’ll fill in what we know about the flag, and replace any non-ascii characters with a ?
:
if len(simulation.found):
solution_state = simulation.found[0]
possible_flag = solution_state.solver.eval(flag, cast_to=bytes)
print(f'Second check result: {possible_flag}')
# filling in the known bytes (start of the flag format 'L3AK')
possible_flag = list(possible_flag)
possible_flag = [ord('L')] + possible_flag
possible_flag[4] = ord('K')
possible_flag[5] = ord('{')
possible_flag = bytearray(possible_flag)
for b in possible_flag:
if b < 0x20 or b >= 0x7f:
possible_flag = possible_flag.replace(bytes([b]), b'?')
print(f'Final result: {possible_flag.decode()}')
After running the script here’s the output:
$ python solve.py
First check results: b'!3 D\x003ng@@4_l\x00fa1d0nt_d\x01_;\x80_?\x01nU4lly}'
Second check result: b'\xf23A\x00\x00angr_4_l\x7ff3_d0nt_do_i\x00_m4nU4lly}'
Final result: L?3AK{angr_4_l?f3_d0nt_do_i?_m4nU4lly}
The missing parts are now obvious, so the flag is: L3AK{angr_4_lif3_d0nt_do_it_m4nU4lly}
or L3AK{angr_4_l1f3_d0nt_do_it_m4nU4lly}
Solve Script
import angr
import claripy
p = angr.Project('./angry_patched_skill_issues', main_opts={'base_addr': 0}, auto_load_libs=False)
initial_state = p.factory.blank_state(addr=0x12aa, add_options={angr.options.LAZY_SOLVES})
good_address = 0x171e
bad_address = 0x1725
flag = claripy.BVS('flag', 0x25*8)
flag_address = 0x44444444
state = initial_state.copy()
state.regs.rax = 0
state.regs.rbp = state.regs.rsp
state.regs.rbx = 0x25
state.regs.rsp -= 0x18
state.memory.store(flag_address, flag)
state.memory.store(state.regs.rbp - 0x18, flag_address, endness='Iend_LE', size=64)
simulation = p.factory.simgr(state)
simulation.explore(find=good_address, avoid=bad_address)
if len(simulation.found):
solution_state = simulation.found[0]
first_check = solution_state.solver.eval(flag, cast_to=bytes)
print(f'First check results: {first_check}')
## Starting check using second function
good_address = 0x1267
bad_address = 0x126e
state = solution_state
state.regs.rip = 0x121d
state.regs.cc_ndep = 0x99999999 # just to get rid of annoying angr warnings
state.regs.rbp = state.regs.rsp
state.memory.store(state.regs.rbp - 0x8, flag_address, endness='Iend_LE', size=64)
state.memory.store(flag_address, flag)
state.regs.rax = flag_address
simulation = p.factory.simgr(state)
simulation.explore(find=good_address, avoid=bad_address)
if len(simulation.found):
solution_state = simulation.found[0]
possible_flag = solution_state.solver.eval(flag, cast_to=bytes)
print(f'Second check result: {possible_flag}')
# filling in the known bytes (start of the flag format 'L3AK')
possible_flag = list(possible_flag)
possible_flag = [ord('L')] + possible_flag
possible_flag[4] = ord('K')
possible_flag[5] = ord('{')
possible_flag = bytearray(possible_flag)
for b in possible_flag:
if b < 0x20 or b >= 0x7f:
possible_flag = possible_flag.replace(bytes([b]), b'?')
print(f'Final result: {possible_flag.decode()}')