(Reverse - CWTE 2025) PORS
TL;DR: Program obfuscation based on sigreturn frames.
PORS is a challenge I authored on the behalf of ECSC Team France for Compete With Team Europe 2025, a training CTF where all the ECSC national teams play against Team Europe. It has been solved by 3 teams during the CTF.
Description:
I suck at pwn, so I do reverse… and I am French btw.
Introduction
For this challenge we are given two files:
- an ELF x86-64 binary
pors
- a file
program.bin
Is that yet another VM challenge? Not this time…
When we run the binary, it prompts us for an input:
$ ./pors
Enter your input: azerazerazer
[-] Wrong input, sorry...
The binary does the following operations:
- open
program.bin
, - map three areas at
0x13370000
,0x42420000
,0xcafe0000
and write some data fromprogram.bin
into the two former, - execute a mysterious syscall and exit.
undefined8 FUN_00401206(void)
{
uint local_30;
uint local_2c;
void *local_28;
void *local_20;
void *local_18;
FILE *local_10;
local_10 = fopen("program.bin","r");
if (local_10 == (FILE *)0x0) {
puts("[-] Failed to load program :/");
exit(1);
}
fread(&local_2c,4,1,local_10);
local_18 = mmap((void *)0x13370000,(long)(int)((local_2c & 0xfffff000) + 0x1000),7,0x22,-1,0);
if (local_18 != (void *)0x13370000) {
puts("[-] init failed, sorry :/");
exit(1);
}
fread((void *)0x13370000,1,(long)(int)local_2c,local_10);
fread(&local_30,4,1,local_10);
local_20 = mmap((void *)0x42420000,(long)(int)((local_30 & 0xfffff000) + 0x1000),3,0x22,-1,0);
if (local_20 != (void *)0x42420000) {
puts("[-] init failed, sorry :/");
exit(1);
}
fread((void *)0x42420000,1,(long)(int)local_30,local_10);
local_28 = mmap((void *)0xcafe0000,0x2000,3,0x22,-1,0);
if (local_28 != (void *)0xcafe0000) {
puts("[-] init failed, sorry :/");
exit(1);
}
syscall();
return 0;
}
Obfuscation
But… where is the actual program? The one that prompts us?
The “mysterious syscall” is a sigreturn
.
004013c4 48 c7 c4 MOV RSP,0x42420000
00 00 42 42
004013cb 48 c7 c0 MOV RAX,0xf
0f 00 00 00
004013d2 0f 05 SYSCALL
Actually, the area at 0x42420000 contains a whole array of sigreturn frames, that encodes partially the logic of the program. Sigreturn frames are especially known for the famous SROP technique used in pwn, that allows reaching an arbitrary RIP value while controlling the values of all registers.
The area at 0x13370000 contains the actual “code” of the program, scattered in small blocks, among them:
- a “dispatcher block”
00000004 48 89 d0 MOV RAX,RDX 00000007 48 83 c0 01 ADD RAX,0x1 0000000b 48 69 c0 IMUL RAX,RAX,0xf8 f8 00 00 00 00000012 48 01 c4 ADD RSP,RAX 00000015 48 c7 c0 MOV RAX,0xf 0f 00 00 00 0000001c 0f 05 SYSCALL
- small blocks of code similar to:
### executed code ### 00000234 c6 44 24 MOV byte ptr [RSP + 0x14],0x0 14 00 ### call to dispatcher block ### 00000239 48 c7 c4 MOV RSP,0x42420000 00 00 42 42 00000240 48 c7 84 MOV qword ptr [RSP + 0x88],0x24 24 88 00 00 00 24 0000024c 48 c7 c0 MOV RAX,0xf 0f 00 00 00 00000253 0f 05 SYSCALL
This pattern reminds of control flow flattening (CFF), where each “basic block” runs an elementary amount of code, and calls a “dispatcher” with the number of the next block to execute (that we call the successor). Then, the dispatcher jump to the successor.
Here, the number of the successor is stored in RDX when the dispatcher
is called. The dispatcher essentially runs the block N
by doing a
sigreturn
with RSP = 0x42420000 + (N + 1) * <size_of_sigreturn_frame>
:
each block is associated to the sigreturn
frame corresponding to its number. After running its actual code, each
block calls back the dispatcher by setting RSP to 0x42420000
,
writing the number of the successor at [RSP + 0x88]
(which
corresponds to RDX in the sigreturn frame) and doing a sigreturn
syscall. This is the purpose of the very first frame at 0x42420000
,
unused by the dispatcher.
Some blocks might jump to several different successors, depending on a
conditional branch: for instance, in the following block, if
AL != BL
, the next block executed is 0x43, otherwise it is 0x35.
00000144 8a 44 24 10 MOV AL,byte ptr [RSP + offset DAT_42420010]
00000148 8a 5c 24 11 MOV BL,byte ptr [RSP + offset DAT_42420011]
0000014c 38 d8 CMP AL,BL
0000014e 48 c7 c4 MOV RSP,0x42420000
00 00 42 42
00000155 48 c7 c0 MOV RAX,0x43
43 00 00 00
0000015c 48 c7 c3 MOV RBX,0x35
35 00 00 00
00000163 48 0f 45 c3 CMOVNZ RAX,RBX
00000167 48 89 84 MOV qword ptr [RSP + offset DAT_42420088],RAX
24 88 00
00 00
0000016f 48 c7 c0 MOV RAX,0xf
0f 00 00 00
00000176 0f 05 SYSCALL
Deobfuscation
The principal difficulty is that the logic is interleaved between both
the code blocks and the values that are previously attributed to
registers before the execution of each block (that are stored in the
sigreturn frames). For instance, in the block above, RSP has
previously been set to 0xcafe1008
by sigreturn
(the area mapped at
0xcafe0000
is actually used as a “stack” for the obfuscated
program).
One possible way to reconstitute the control flow of the program is as follows:
- associate each block to its sigreturn frame by identifying the RIP value of the sigreturn frame,
- emulate the “dispatcher” by replacing the sigreturns with direct jumps to the next block, handling carefully the conditionals.
In a nutshell, one can generate a new program_deobf.bin
where the
blocks are of the form:
mov r8, <r8_in_the_corresponding_sigreturn_frame>
mov r9, <r9_in_the_corresponding_sigreturn_frame>
...
mov rdi, <rdi_in_the_corresponding_sigreturn_frame>
mov rsi, <rsi_in_the_corresponding_sigreturn_frame>
...
[the code block]
mov rax, <the_offset_of_the_successor>
jmp rax
For blocks with conditionals, the final part is replaced with
mov rax, <offset_successor1>
mov rbx, <offset_successor2>
j<cond> taken
jmp rax
taken:
jmp rbx
The blocks being shuffled and not in the same order as the corresponding sigreturn frames, particular attention is required for this step.
The full script to deobfuscate the program is available here.
Solving
Then, the pseudo-code can more or less be reconstituted in a
decompiler. After googling a bit or asking our favorite LLM, we figure
out that the program encodes a 9x9 Suguru puzzle. The areas are
hardcoded in a 81-byte buffer areas
whose bytes are equal to values
in 0-18. Each area is numbered, and a given square (i,j)
belongs to
the area N
if areas[i*9+j] = N
. The constraints (already placed
numbers) are also hardcoded in another buffer.
After prompting the user for input, the program checks its validity with respect to data concerning the grid. If the input is correct, it is hashed with SHA512 and xored with a constant (the decryption function is actually in the main binary), which prints the flag. Otherwise, it calls another function that prints the “wrong input” message.
The expected input is the numbers in the solved Suguru grid, as a 81-char long string. Suguru solvers using Z3 can easily be found on github.
The solver script is available here.
After finding the (unique) solution:
141253514235414232142323541235141232142323541231514132142323251351541434143232521
, we input it to the binary to get the flag.
$ ./pors
Enter your input: 141253514235414232142323541235141232142323541231514132142323251351541434143232521
[+] Congratulations! You can validate the challenge with this flag: CWTE{pr0gr4mm4t10n_0r13nt33_r3t0ur_s1gn4l}
FLAG: CWTE{pr0gr4mm4t10n_0r13nt33_r3t0ur_s1gn4l}
. It means
“signal return oriented programming” in French, and thanks to a great
coincidence, “PORS” is also “SROP” in reverse :-)