Roo CTF 2025: Split-Screen (tuxops)


title: “split-screen” date: “2025-10-25” author: “TechnoDot (technodot)” description: ""

TechnoDot (https://github.com/TechDudie)

Provided with the split-screen executable, find the flag.

REQUIREMENTS: Ghidra, WSL (for Windows); we don’t talk about Mac

Try running the executable. On Windows, run it in WSL.

┏━(Message from Kali developers) ┃ ┃ This is a minimal installation of Kali Linux, you likely ┃ want to install supplementary tools. Learn how: ┃ ⇒ https://www.kali.org/docs/troubleshooting/common-minimum-setup/ ┃ ┗━(Run: “touch ~/.hushlogin” to hide this message) (base) ┌──(technodot㉿VIVOBOOK)-[~] └─$ cd /mnt/c/Users/technodot/Downloads/ (base) ┌──(technodot㉿VIVOBOOK)-[/mnt/c/Users/technodot/Downloads] └─$ chmod +x ./split-screen (base) ┌──(technodot㉿VIVOBOOK)-[/mnt/c/Users/technodot/Downloads] └─$ ./split-screen [timeline] expectation and reality drift apart. press enter to sync> you keep rewinding but nothing changes. try again later. (base) ┌──(technodot㉿VIVOBOOK)-[/mnt/c/Users/technodot/Downloads] └─$

Loading the executable into the dogbolt decompiler, we can see different decompilations to varying detail. Hex-Rays offers the clearest code, with all of the print statements verbatim in the code.

| /* This file was generated by the Hex-Rays decompiler version 9.2.0.250908. Copyright (c) 2007-2021 Hex-Rays <[email protected]> Detected compiler: GNU C++ */ // ... code ... //----- (00000000004011E0) ---------------------------------------------------- __int64 __fastcall main(int a1, const char **a2, char **a3) { // ... code ... if ( a1 > 1 ) { v3 = a2[1]; v4 = strcmp(v3, "--debug"); v5 = stderr; if ( v4 ) { if ( strcmp(v3, "--force-align") ) { v6 = strcmp(v3, "--help"); v7 = *a2; if ( v6 ) { fprintf(v5, "usage: %s [--debug] [--force-align]\n", v7); return 1; } else { fprintf(v5, "usage: %s [--debug] [--force-align]\n", v7); return 0; } } fwrite("--force-align is not available in release builds\n", 1u, 0x31u, v5); } else { fwrite("--debug is not available in this build\n", 1u, 0x27u, stderr); } return 2; } if ( !isatty(0) || !isatty(1) || (printf("[timeline] expectation and reality drift apart. press enter to sync> "), fflush(stdout), !fgets(s, 8, stdin)) ) { v16[3] = 0; } // ... code ... if ( (unsigned int)sub_401810(v16, 0) ) { printf("flag: %s\n", s); return 0; } else { if ( v16[0] ) { puts("You rewind too far. This scene is gone."); puts("flag: roo{false_reflection_sequence}"); } else if ( v16[1] ) { puts("You rewind too far. This scene is gone."); } else { puts("you keep rewinding but nothing changes. try again later."); } return v16[0] != 0; } } // ... code ... | | :—- |

We can identify two arguments, --debug and --force-align, as well as the prompt and potential output. We see two potential flags. Trying roo{false_reflection_sequence} fails; it’s a red herring. Because this executable is not particularly complex, short-circuiting the corresponding if statements is the easiest approach. Load the executable into Ghidra, and analyze and decompile it. Beginning at the entrypoint and doing some digging, we can find the main function at 0x004011e0.

************************************************************** * FUNCTION * ************************************************************** undefined processEntry entry() undefined <UNASSIGNED> <RETURN> undefined8 Stack[-0x10]:8 local_10 XREF[1]: 0040147e(*) entry XREF[4]: Entry Point(*), 00400018(*), 00402244, 00402278(*) 00401470 31 ed XOR EBP,EBP 00401472 49 89 d1 MOV R9,RDX 00401475 5e POP RSI 00401476 48 89 e2 MOV RDX,RSP 00401479 48 83 e4 f0 AND RSP,-0x10 0040147d 50 PUSH RAX 0040147e 54 PUSH RSP=>local_10 0040147f 45 31 c0 XOR R8D,R8D 00401482 31 c9 XOR ECX,ECX 00401484 48 c7 c7 MOV RDI,LAB_004011e0 e0 11 40 00 0040148b ff 15 47 CALL qword ptr [-><EXTERNAL>::__libc_start_main] undefined __libc_start_main() 2b 00 00 = 00405008 00401491 f4 HLT

There, in the decompiler, we are able to see the potential output.

// ... code ... 85 iVar2 = FUN_00401810(&iStack_a8,0); 86 if (iVar2 == 0) { 87 if (iStack_a8 == 0) { 88 if (iStack_a4 == 0) { 89 puts("you keep rewinding but nothing changes. try again later."); 90 } 91 else { 92 puts("You rewind too far. This scene is gone."); 93 } 94 } 95 else { 96 puts("You rewind too far. This scene is gone."); 97 puts("flag: roo{false_reflection_sequence}"); 98 } 99 bVar9 = iStack_a8 != 0; 100 } 101 else { 102 printf("flag: %s\n",abStack_58); 103 bVar9 = false; 104 } 105 return bVar9;

We see that the other flag only prints when FUN_00401810(&iStack_a8,0) != 0. We could analyze the exact conditions for that to occur, and recreate them. OR, we could take the easy way out: short circuiting the if statement. By directly modifying the opcodes in the executable, we can control the execution of the program at the lowest possible level. Selecting the if condition, we see that it matches up with the check and conditional jump from 0x00401367 to 0040136e. Highlighting the instructions from 0x00401367 to 0x00401386, we see the if condition and the else block highlighted, like so:

00401367 85 c0 TEST EAX,EAX // tests the condition 00401369 0f 84 8e JZ LAB_004013fd // jumps to the true block 00 00 00 0040136f 4c 89 e6 MOV RSI,R12 00401372 48 8d 3d LEA RDI,[s_flag:_%s_00402021] = "flag: %s\n" // loads location of flag in memory in the RDI register a8 0c 00 00 00401379 31 c0 XOR EAX,EAX 0040137b e8 30 fd CALL <EXTERNAL>::printf int printf(char * __format, ...) // prints it out ff ff 00401380 31 c0 XOR EAX,EAX 00401382 e9 00 ff JMP LAB_00401287 ff ff 86 if (iVar2 == 0) { // ... 100 } 101 else { 102 printf("flag: %s\n",abStack_58); 103 bVar9 = false; 104 }

TEST EAX, EAX instruction performs a bitwise AND on register EAX and itself, discarding the result. The only output is ZF, which will be set to 1 if and only if the result was 0. Then, the JZ instruction jumps if and only if the ZF flag is set to 1. Essentially, if register EAX is not 0, then execution will jump to the true block.

We need the printf call to execute, so the conditional jump from 0x00401369 to 0x0040136e can NOT execute. The easiest way to do this is to completely delete the JZ instruction and replace all bytes with 0x90 NOP (No Operation) instructions. These will function as padding, avoiding shifting any addresses.

To do this, navigate your cursor to 0x00401369 and press Ctrl+Alt+G, or right click and select “Patch Instruction”. Delete the instruction by clearing both red-outlined boxes. Select the one on the left, and type “NOP” (No Operation). Select the option labeled 0x90, NOT NOP verbatim. This will overwrite one byte, and leave the remaining 5 bytes as-is, malformed. Proceed to overwrite all 6 bytes of the instruction. 0x00401369 to 0x0040136e should be replaced with all 0x90s. The decompiler should automatically update. Verify that the flag printf statement is indented the same as the rest of the main function. Press O, or select File > Export Program... to export your modified binary, and set the format to Original File. Navigate back to your terminal, and run the modified executable.

(base) ┌──(technodot㉿VIVOBOOK)-[/mnt/c/Users/technodot/Downloads] └─$ chmod +x ./split-screen-shortcircuit (base) ┌──(technodot㉿VIVOBOOK)-[/mnt/c/Users/technodot/Downloads] └─$ ./split-screen-shortcircuit [timeline] expectation and reality drift apart. press enter to sync> flag: roo{500_days_of_summer} (base) ┌──(technodot㉿VIVOBOOK)-[/mnt/c/Users/technodot/Downloads] └─$

The challenge could have absolutely been solved by reverse engineering the conditions, but working smarter and not harder will gain you aura.