SKIP TO MAIN CONTENT

[ WRITEUP NODE / FIELD REPORTS ]

SOLVED CHALLENGES & FIELD ANALYSIS

SECURITY RESEARCH KNOWLEDGE TECHNIQUES
B3S/WRITEUPS/AFRICC-QUALIFIERS-2027-FORTRESS-WRITEUP
← BACK TO ARCHIVE
EVENT: INDEPENDENTCATEGORY: Reverse EngineeringPOINTS: 300 PTS

AFRICC Qualifiers 2027 - Fortress Writeup

AUTHORED BY:@bealthguy8/15/2026

Fortress — Reverse Engineering Writeup

Category: Reverse Engineering / Windows PE Binary: fortr3ss.exe (PE32+ x86-64 console, GCC 15-win32 C++, not stripped) Flag: Africc{R3v3rs3_Eng1n33r1ng_1s_Fun}


1. Recon

$ file fortr3ss.exe
PE32+ executable (console) x86-64, for MS Windows

strings reveals the layout right away:

Error
Africc{
Correct!
Wrong!
FLAGP4
FLAGP3r0
FLAGP2
FLAGP1

rabin2 -i/r2 symbols give us the roadmap:

sym.main        @ 0x1400bfe40
sym.init_apis__ @ 0x1400015d0
sym.is_bot__    @ 0x140001bd0
sym.check_part  @ 0x140001cd0   (check_part(unsigned char const*, unsigned long long,
                                           unsigned char, std::string const&))

So main reads a line, splits it, and validates up to four FLAGP* sections through check_part. init_apis__ resolves a few API pointers dynamically and is_bot__ decides which key set is used.

2. main() flow

main (disassembled with r2 -c 'aaa; s sym.main; pdf'):

  1. init_apis() — if it fails → print Error, exit.
  2. Call IsDebuggerPresent() (stored in 0x1400f1040) — if non-zero → Error.
  3. is_bot() — returns 1 for non-interactive/automated environments (see below).
  4. Print a prompt (an obfuscated string), getline the answer into a std::string.
  5. Validate in order:
    • length must be > 6,
    • first 7 chars must equal Africc{ (memcmp(s1, "Africc{", 7)),
    • remaining content is split on _ (0x5f) into 4 parts via substr + find,
    • each part goes to check_part(const, len, key, part).
  6. and the four return values; all non-zero → Correct!, else Wrong!.

The four check_part calls in the non-bot path:

Call rcx (const data) rdx (len) r8 (key) arg4 (user part)
1 0x1400c608e 8 'A' part1
2 0x1400c6076 12 'B' part2
3 0x1400c605e 3 'C' part3
4 0x1400c604e 4 'D' part4

3. check_part() — single-byte XOR

bool check_part(unsigned char const* in, unsigned long long len,
                unsigned char key, std::string const& ref)
{
    std::string out;            // reserved, built char by char
    for (unsigned long long i = 0; i < len && in[i] != class="hljs-string">&#039;\0&#039;; i++)
        out += (char)(in[i] ^ key);     // XOR each byte with the key
    return out == ref;                  // compare with the user&#039;s part
}

So each expected part is simply:

part_n = const_bytes_n XOR key_n

4. is_bot() — anti-automation key switch

bool is_bot() {
    if (!_isatty(_fileno(stdin)))  return true;   // piped input
    if (!_isatty(_fileno(stdout))) return true;
    if (!getenv(class="hljs-string">"TERM") || !getenv(class="hljs-string">"USER")) return true;
    if (!strcmp(getenv(class="hljs-string">"USER"), class="hljs-string">"root")) return true;
    if (getenv(class="hljs-string">"PYTHONPATH") || getenv(class="hljs-string">"VIRTUAL_ENV") || getenv(class="hljs-string">"CI")) return true;
    ... // GetConsoleMode-based heuristic
    return !ok;
}

If is_bot() returns 1, main switches the keys to Q/R/S/T (0x51/52/53/54) instead of A/B/C/D. That is an anti-scripting trap: run it with piped stdin (echo flag | ./fortr3ss.exe) and the expected parts change to garbage. A real interactive terminal keeps keys A/B/C/D.

5. Extracting the constants

Dump .rdata around the FLAGP* markers (r2 -c 'px 96 @ 0x1400c6040'):

0x1400c6040  5772 6f6e 6721 0000 464c 4147 5034 0231   Wrong!..FLAGP4.1
0x1400c6050  2a00 0000 0000 0000 464c 4147 5033 7230   *.......FLAGP3r0
0x1400c6070  464c 4147 5032 072c 2573 2c71 7130 732c   FLAGP2.,%s,qq0s,
0x1400c6080  2500 0000 0000 0000 464c 4147 5031 1372   %.......FLAGP1.r
0x1400c6090  3772 3332 7200 0000                      7r32r...

The constant for each part sits right after its FLAGP* marker:

Part Constant bytes (hex) Key Decoded
P1 @ 0x1400c608e 13 72 37 72 33 32 72 00 A R3v3rs3
P2 @ 0x1400c6076 07 2c 25 73 2c 71 71 30 73 2c 25 00 B Eng1n33r1ng
P3 @ 0x1400c605e 72 30 00 C 1s
P4 @ 0x1400c604e 02 31 2a 00 D Fun

Decoding script:

parts = [
    (class="hljs-string">"13 72 37 72 33 32 72 00", 0x41),
    (class="hljs-string">"07 2c 25 73 2c 71 71 30 73 2c 25 00", 0x42),
    (class="hljs-string">"72 30 00", 0x43),
    (class="hljs-string">"02 31 2a 00", 0x44),
]
for h, k in parts:
    b = bytes.fromhex(h)
    print(class="hljs-string">"".join(chr(x ^ k) for x in b if x))   # stops at the null terminator
R3v3rs3
Eng1n33r1ng
1s
Fun

6. Flag

Joining the four parts with _ inside the Africc{...} envelope:

Africc{R3v3rs3_Eng1n33r1ng_1s_Fun}

(Running it interactively under Windows prints Correct!; a naive echo flag | wine fortr3ss.exe instead hits the Error path from the IsDebuggerPresent/init check and the Q/R/S/T key switch — both anti-automation measures.)

7. Takeaways

  • Follow the markers: the FLAGP1..FLAGP4 strings conveniently label each constant block right before its encrypted bytes.
  • Anti-automation ≠ anti-re: is_bot() doesn't change the math, it only swaps the key byte, so static decoding is unaffected.
  • Always check IsDebuggerPresent/init-bail paths — they print Error before the flag check, which can mislead you into thinking your answer is wrong.