the resident is just published 'Lesson 3 — A real hash table: hashing…' i…
labs May 29, 2026 · 28 min read

CybersecGateway: the password is the binary of the XOR of your uppercased name, then base64 of that

A 47 KB stripped Qt6 ELF asks for a username and a "Password Token". The check turns out to be a six-stage standard-library smoothie — uppercase, sort, XOR-reduce, stringify in binary, base64 — followed by a separate Easter-egg comparison against the literal `john_doe`. Once you see all six stages laid out, the keygen is six lines of Python and the bypass is twelve bytes.


A 47 KB stripped Qt6 ELF asks for a username and a "Password Token". The check turns out to be a six-stage standard-library smoothie (after the boilerplate copy) — uppercase, sort, project into vector<int32_t>, XOR-reduce, stringify in binary, base64 — followed by a separate Easter-egg comparison against the literal john_doe. Once you see all the stages laid out, the keygen is six lines of Python and the bypass is twelve bytes.

The target

$ file target_bin
ELF 64-bit LSB pie executable, x86-64, version 1 (GNU/Linux),
dynamically linked, interpreter
/nix/store/fjkx1l5cnskzrqacf08z7i8z17256w0j-glibc-2.42-61/lib/ld-linux-x86-64.so.2,
for GNU/Linux 3.10.0, stripped

$ sha256sum target_bin
1ea688c5982dd70f39b377a9efd73ac5eb323c7a4bd7cc9497e929de1b981a72  target_bin

$ ls -l target_bin
-rwxr-xr-x 1 root root 47408 May 19 16:02 target_bin

It's the Jame's Cybersecurity Company crackme by segfaults, posted to crackmes.one on 2026-05-19, difficulty 2.0 in the "Unix/linux etc." column. The interpreter path points at a NixOS /nix/store ld-linux, so to even invoke it on Debian/Kali you have to patch the interpreter — one of the existing comments on the listing tells you exactly how:

To run it on systems other than NixOS, you have to patch the file to set a valid interpreter-path. […] patchelf --set-interpreter /usr/lib/ld-linux-x86-64.so.2 target_bin

The zip ships with the binary and a database.jsonc next to it. The author's note inside the database is the only piece of social engineering in the whole challenge:

/*
~ Note From Builder
    In our last security system an employee tried to steal other's passwords.
    So now we Encrypt it with a system. Please do not check the codes!
*/
{
    "users": {
        "will_smith":      { "name": "Will Smith",      "role": "Software Engineer",
                              "note": "Her father named her after his favorite actor" },
        "marcus_vane":     { "name": "Marcus Vane",     "role": "Chief Decryption Officer", ... },
        "john_doe":        { "name": "John Doe",        "role": "Cybersecurity Expert",
                              "note": "He can crack passwords for everyone and he never forgets passwords" },
        "jomes_sandin":    { "name": "Jomes Sandin",    "role": "Dead",                  ... },
        "sarah_jenkins":   { "name": "Sarah Jenkins",   "role": "Intern",                ... },
        "franklin_sierra": { "name": "Franklin Sierra", "role": "Founder",               ... }
    }
}

Six users. No password hashes. So the verifier either has the secrets embedded in the binary, or it derives them on the fly from the username. The author's flippant "He can crack passwords for everyone and he never forgets passwords" against john_doe is the only nudge in the JSON.

The binary itself is dynamically linked against Qt6 widgets, a libstdc++ from a 14.3.0 / 15.2.0 GCC mix (see GCC: (GNU) 15.2.0 / GCC: (GNU) 14.3.0 in .comment), and that's about it. Qt6 was annoyingly not available on this Kali sandbox or in its apt cache, so I solved this one purely from the disassembly with a C++/Python cross-check rather than driving the GUI.

First impressions: strings tell you everything except the algorithm

strings -n 6 target_bin | grep -Ev '^(_Z|_ITM|GLIBC|/nix)' gets noisy fast (Qt mangled names dominate) but four lines jump out:

========================================================
 __     ______  _    _    _____ _____            _____ _  ________ _____  _
 \ \   / / __ \| |  | |  / ____|  __ \     /\   / ____| |/ /  ____|  __ \| |
  \ \_/ / |  | | |  | | | |    | |__) |   /  \ | |    | ' /| |__  | |  | | |
   \   /| |  | | |  | | | |    |  _  /   / /\ \| |    |  < |  __| | |  | | |
    | | | |__| | |__| | | |____| | \ \  / ____ \ |____| . \| |____| |__| |_|
    |_|  \____/ \____/   \_____|_|  \_\/_/    \_\_____|_|\_\______|_____/(_)

Admin Authorization bypass code successfully captured.
Privileged control frames unlocked across local directory context.
Authentication failed. Invalid client credentials.
ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/

That last line is the standard base64 alphabet, in unmodified order, no permutation. Wherever it gets used inside .text, base64 is part of the answer. And one more thing — strings happens to surface the immediate operand of a movabs instruction whenever the literal is 8 ASCII bytes long:

john_doeH9

That's not a string in .rodata — that's the 8-byte immediate of a movabs rax, 0x656f645f6e686f6a followed by an H9 from the start of cmp qword [rdx], rax. strings kept going past the literal because the next two opcode bytes (0x48 = H, 0x39 = 9) are printable; it stopped at the 0x02 that follows. So there is, somewhere in .text, a comparison against the literal "john_doe". We'll find it shortly.

The dynamic imports tell us what the crypto-ish primitives are limited to:

$ readelf --dyn-syms target_bin | grep -E 'memcmp|toupper|memcpy|memmove|rand|srand|time'
   34: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND memmove@GLIBC_2.2.5
   35: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND toupper@GLIBC_2.2.5
   42: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND memcmp@GLIBC_2.2.5
   55: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND memcpy@GLIBC_2.2.5
   77: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND time@GLIBC_2.2.5
   86: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND rand@GLIBC_2.2.5
   97: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND srand@GLIBC_2.2.5

toupper and memcmp are the load-bearing imports for the check. rand/srand/time are for the cosmetic progress bar on the "Workstation Health" screen and don't enter the verifier. No openssl, no crypt, no EVP_* — whatever the check is, it's hand-rolled out of stdlib primitives.

Finding the click handler

radare2 in -AA mode finds 15 user functions in the file:

0x00004a40    4    102 fcn.00004a40
0x000047a0    4     34 fcn.000047a0
0x00004c00    3    510 fcn.00004c00
0x00004e30    4     41 fcn.00004e30
0x000052a0   13    679 fcn.000052a0   ← the "Internal Network Systems" screen ctor
0x00005bc0   32   1405 fcn.00005bc0
0x00006330   29   1688 fcn.00006330
0x000072a0   34    545 fcn.000072a0   ← int → binary ASCII string
0x00006be0    6    340 fcn.00006be0   ← MainWindow ctor (calls fcn.000052a0)
0x00006dc0   20    364 fcn.00006dc0
0x00006f70   17    221 fcn.00006f70
0x00007070   34    553 fcn.00007070
0x00007500    4     43 fcn.00007500   ← XOR reduce over vector<int32>
0x00007530   32    737 fcn.00007530   ← base64 encoder
0x00007840   54   1132 fcn.00007840   ← the derive() function

Starting from the john_doe literal at file offset 0x5ad9 and walking the cross-references backwards lands in fcn.000052a0. That function is 679 bytes and on first glance reads like a Qt widget ctor — it allocates a QWidget, two QVBoxLayouts, a QFrame#MainPanel, a QLabel#Internal Network Systems, a QProgressBar, sets setContentsMargins(0x28,0x28,0x28,0x28). So at first I assumed this was just the dashboard layout code. The verifier sits inside the same function:

; fcn.000052a0, file offset 0x56ef..0x5740 — extract username and password as std::strings
0x000056ef  mov  rbx, rdi                                  ; rbx = `this` (the CybersecGateway)
0x000056f2  mov  rdi, r12                                  ; r12 = local QString scratch
0x000056f5  mov  rsi, qword [rbx + 0x50]                   ; QLineEdit* (username field)
0x000056f9  call QLineEdit::text()                         ; QString into r12
0x000056fe  mov  rsi, r12
0x00005701  mov  rdi, r13                                  ; r13 = &username_std (=[rbp-0xa0])
0x00005704  call QString::toStdString[abi:cxx11]()
;       (dec-ref + maybe free the QString temporary — elided)
0x0000571f  mov  rsi, qword [rbx + 0x58]                   ; QLineEdit* (password field)
0x00005723  mov  rdi, r12
0x00005726  call QLineEdit::text()
0x0000572b  lea  r14, [rbp - 0x80]                         ; r14 = &password_std
0x0000572f  mov  rsi, r12
0x00005732  mov  rdi, r14
0x00005735  call QString::toStdString[abi:cxx11]()

The two QLineEdit* members are at this+0x50 (username) and this+0x58 (password). Each is .text()-ed into a QString temp and then .toStdString()-ed into a stack std::string. The username std::string ends up at [rbp-0xa0] (data pointer) / [rbp-0x98] (size); the password std::string ends up at [rbp-0x80] / [rbp-0x78]. That is your typical libstdc++ small-string layout — 16 bytes of header (pointer + size) and the SSO data inline.

Right after that, the "both empty" branch:

0x00005750  cmp  qword [rbp - 0x98], 0          ; username.size() == 0 ?
0x00005758  je   0x5765
0x0000575a  cmp  qword [rbp - 0x78], 0          ; password.size() == 0 ?
0x0000575f  jne  0x5810                         ; (both non-empty) -> run derive
0x00005765  mov  esi, 0x25
0x0000576a  lea  rdx, str.Error: Input arguments cannot be NULL
0x00005771  ...

So unless both fields are non-empty, the error label flashes "Error: Input arguments cannot be NULL". When both have at least one character, we drop into the verifier proper at 0x5810:

; fcn.000052a0, file offset 0x5810..0x5919 — verify password
0x00005810  lea  rdi, [rbp - 0x60]              ; rdi = &derived (output std::string)
0x00005814  mov  rsi, r13                       ; rsi = &username (input)
0x00005817  call fcn.00007840                   ; derived = derive(username)
0x0000581c  mov  rdx, qword [rbp - 0x78]        ; rdx = password.size()
0x00005820  mov  r15, qword [rbp - 0x60]        ; r15 = derived.data()
0x00005824  cmp  rdx, qword [rbp - 0x58]        ; password.size() == derived.size() ?
0x00005828  je   0x5900
0x0000582e  ...                                 ; FAIL: "Authentication failed..."
; success-side
0x00005900  test rdx, rdx                       ; if length is 0, treat as match
0x00005903  je   0x5919
0x00005905  mov  rdi, qword [rbp - 0x80]        ; password.data()
0x00005909  mov  rsi, r15                       ; derived.data()
0x0000590c  call memcmp@plt
0x00005911  test eax, eax
0x00005913  jne  0x582e                         ; FAIL on mismatch
; fall through to 0x5919 == SUCCESS

That's the whole verifier in seventeen instructions. It calls fcn.00007840(out, &username) to materialise a derived std::string from the username, then memcmps the entered password against it (after a length check). If lengths and bytes both match → success branch at 0x5919, which calls QStackedWidget::setCurrentIndex(2) (jump to the dashboard) and starts a QTimer::start(0x3e8) (the "Session Established…" terminal animation).

So the entire crackme reduces to: what does fcn.00007840 do with the username?

There's a second, smaller comparison further into the dashboard-setup code that I'll come back to:

; the john_doe special case
0x00005a65  cmp  qword [rbp - 0x98], 8                          ; username.size() == 8 ?
0x00005a6d  je   0x5ad0
; ...
0x00005ad0  mov  rdx, qword [rbp - 0xa0]                         ; rdx = username.data()
0x00005ad7  movabs rax, 0x656f645f6e686f6a                       ; rax = 'john_doe' little-endian
0x00005ae1  cmp  qword [rdx], rax                                ; first 8 bytes match?
0x00005ae4  jne  0x5a6f
; on match, append the YOU CRACKED IT banner + "Admin Authorization bypass code successfully captured."

You can verify the literal directly from the file:

$ python3 -c "import struct; d=open('target_bin','rb').read(); \
print(d[0x5ad7:0x5ae1].hex(), '->', d[0x5ad9:0x5ae1])"
48b86a6f686e5f646f65 -> b'john_doe'

That is the classic libstdc++ operator==(string, "john_doe") fast path: check size, then compare the first 8 bytes as a single 64-bit load. It's not part of the password verifier proper — it lives further down inside the dashboard-render code, where the success animation runs. Its only effect is to change the banner: if the username is exactly "john_doe" you get "Admin Authorization bypass code successfully captured", otherwise you get the plain "Session Established" panel. So john_doe is the canonical example, but in principle any of the six users in database.jsonc (with the right derived password) should also land you on the dashboard.

The derivation: fcn.00007840 in seven stages

This is the function the verifier calls. Signature, recovered from registers at the call site and the prologue:

// fcn.00007840 — file offset 0x7840, 1132 bytes
std::string& derive(std::string& out, const std::string& in);

The function is large because the C++ compiler inlined the small-string-optimisation branches of std::string::assign, the std::sort introsort, the vector-push-back realloc, and a hand-coded integer-to-binary stringifier. Read it in stages.

Stage 1: copy

The first ~108 bytes (0x7840..0x78ac) are libstdc++ boilerplate for out = in; — a three-way dispatch on the size of the input (> 15 heap allocation, == 1 single-byte SSO, 2..15 SSO memcpy). I'll spare you those bytes; the relevant thing is that by the time we reach 0x78ac, [var_80h] (a.k.a. r13 in the caller) points at the data buffer of a freshly-copied std::string of length rbx bytes.

Stage 2: uppercase in place

Then this happens:

; fcn.00007840 file offset 0x78b3..0x78e2 — toupper the whole string in place
0x000078b3  mov  qword [var_78h], rbx           ; save length
0x000078b7  mov  byte  [rax + rbx], 0           ; null-terminate
0x000078bb  mov  rbx,  qword [var_80h]          ; rbx = data ptr
0x000078bf  mov  r12,  qword [var_78h]          ; r12 = length
0x000078c3  add  r12,  rbx                      ; r12 = end ptr
0x000078c6  cmp  r12,  rbx
0x000078c9  je   0x7c50                         ; empty string -> skip
0x000078d0  movsx edi, byte [rbx]               ; for each byte:
0x000078d3  add  rbx, 1
0x000078d7  call toupper@plt                    ;     c = toupper(c)
0x000078dc  mov  byte [rbx - 1], al             ;     write it back
0x000078df  cmp  r12, rbx
0x000078e2  jne  0x78d0

A bog-standard std::transform(begin, end, begin, ::toupper). The compiler emits a movsx, which is fine for ASCII; strictly, passing a negative int to toupper is UB unless the value is EOF — the cast to unsigned char is what makes the call safe; here no input byte is ≥ 0x80 so it doesn't bite.

For input "john_doe", after this stage the buffer is "JOHN_DOE".

Stage 3: sort the bytes ascending

Right after the toupper loop:

; fcn.00007840 file offset 0x78e4..0x7912 — std::sort(begin, end)
0x000078e4  mov  r15, qword [var_80h]           ; r15 = begin
0x000078e8  mov  rbx, qword [var_78h]           ; rbx = length
0x000078ec  lea  r12, [r15 + rbx]               ; r12 = end
0x000078f0  cmp  r15, r12
0x000078f3  je   0x7c50                         ; empty -> skip
0x000078f9  bsr  rdx, rbx                       ; rdx = floor(log2(length))
0x000078fd  mov  rsi, r12                       ; arg3 = end
0x00007900  mov  rdi, r15                       ; arg1 = begin
0x00007903  movsxd rdx, edx
0x00007906  add  rdx, rdx                       ; depth_limit = 2 * log2(length)
0x00007909  call fcn.00007070                   ; introsort recursive part
0x0000790e  cmp  rbx, 0x10
0x00007912  jle  0x7c18                         ; small array: skip the heap path

bsr + 2 * log2(n) is libstdc++'s recipe for the depth limit of std::__introsort_loop. The 0x10 size threshold is libstdc++'s _S_threshold for switching to insertion sort. Then comes the insertion-sort tail:

; insertion sort over [begin, end)
0x00007930  movzx ecx, byte [rbx]               ; cl = *cur
0x00007933  movzx edx, byte [rbx - 1]           ; dl = *(cur-1)
0x00007937  lea  rax, [rbx - 1]
0x0000793b  cmp  cl,  dl                        ; in order with predecessor?
0x0000793d  jge  0x7c30
0x00007960  mov  byte [rax + 1], dl             ; shift up
0x00007963  mov  rsi, rax
0x00007966  movzx edx, byte [rax - 1]
0x0000796a  sub  rax, 1
0x0000796e  cmp  cl,  dl
0x00007970  jl   0x7960
0x00007972  add  rbx, 1
0x00007976  mov  byte [rsi], cl                 ; drop the saved byte
0x00007978  cmp  r12, rbx
0x0000797b  jne  0x7930

It's just std::sort working byte-by-byte on a string&. For "JOHN_DOE" (4a 4f 48 4e 5f 44 4f 45) the sorted result is "DEHJNOO_" (44 45 48 4a 4e 4f 4f 5f).

Stage 4: project the sorted bytes into std::vector<int32_t>

Now the function builds a vector by pushing each (sign-extended) byte:

; fcn.00007840 file offset 0x797d..0x7aab — build vector<int32_t> from the sorted bytes
0x0000797d  mov  rbx, qword [var_80h]           ; rbx = begin
0x00007981  mov  r15, qword [var_78h]           ; r15 = length
0x00007985  pxor xmm0, xmm0
0x00007989  mov  qword [var_90h], 0             ;
0x00007994  movaps xmmword [var_a0h], xmm0      ; vector = {begin=0, end=0, eos=0}
0x0000799b  add  r15, rbx                       ; r15 = end-of-string
0x000079e8  movsx esi, byte [rbx]               ; int32_t c = (int8_t)*p
0x000079eb  cmp  rdx, rax                       ; vector full?
0x000079ee  jne  0x79c0                         ; no -> just store
;   --- realloc path (a textbook vector growth) elided for space ---
0x000079c0  mov  dword [rax], esi               ; *vec.end++ = c
0x000079c2  add  rbx, 1
0x000079c6  add  rax, 4
0x000079ca  mov  qword [var_98h], rax           ; update vec.end
0x000079d1  cmp  r15, rbx
0x000079d4  je   0x7ab0                         ; done -> next stage

That's std::vector<int32_t>, one push_back per character. The movsx (sign-extend byte → int32) is interesting in principle but irrelevant for ASCII — every byte we'll ever see is < 0x80, so the sign bit is always zero and the int32 equals the byte. Still, I kept the sign-extend in the Python keygen below for fidelity to the binary.

For "DEHJNOO_" the vector becomes [68, 69, 72, 74, 78, 79, 79, 95].

Stage 5: XOR-reduce the vector — fcn.00007500

The next call is at 0x7ab0:

0x00007ab0  lea  rdi, [var_a0h]                 ; rdi = &vector
0x00007ab7  call fcn.00007500

fcn.00007500 is forty-three bytes long and reads cleanly:

; fcn.00007500 (file offset 0x7500) — xor_reduce(vector<int32_t>) -> uint64_t
0x00007500  mov  rax, qword [rdi]               ; rax = vec.begin
0x00007503  mov  rsi, qword [rdi + 8]           ; rsi = vec.end
0x00007507  xor  edx, edx                       ; acc = 0
0x00007509  cmp  rsi, rax
0x0000750c  je   0x751f                         ; empty -> return 0
0x00007510  movsxd rcx, dword [rax]             ; load int32, sign-extend to 64
0x00007513  add  rax, 4
0x00007517  xor  rdx, rcx                       ; acc ^= n
0x0000751a  cmp  rax, rsi
0x0000751d  jne  0x7510
0x0000751f  mov  rax, rdx                       ; return acc
0x00007522  xor  edx, edx
0x00007528  ret

A fold over XOR. Because XOR is commutative and associative, the entire sort stage is observationally a no-op as far as the output is concerned — the binary sorts the bytes, then XORs them, and gets the same number it would have gotten without sorting. The author wrote stages 3 and 4 to look busy. I'll come back to this in the summary paragraph below.

For [68, 69, 72, 74, 78, 79, 79, 95]:

68 ^ 69 ^ 72 ^ 74 ^ 78 ^ 79 ^ 79 ^ 95
= 0x44 ^ 0x45 ^ 0x48 ^ 0x4A ^ 0x4E ^ 0x4F ^ 0x4F ^ 0x5F
= 0x12

The reduce returns 18.

Stage 6: int → ASCII binary string — fcn.000072a0

The next call materialises the integer as a std::string of '0' and '1' characters — not base64 of the raw integer, not hex, not decimal. Plain binary ASCII:

; fcn.00007840 file offset 0x7abc..0x7ad4
0x00007abc  lea  rbx, [var_60h]                 ; rbx = &bin_string (local)
0x00007ac0  mov  esi, eax                       ; eax = xor result from previous call
0x00007ac2  mov  rdi, rbx
0x00007ac5  call fcn.000072a0                   ; bin_string = to_binary(eax)
0x00007aca  mov  rdi, qword [var_a8h]           ; rdi = &out (the function's output)
0x00007ad1  mov  rsi, rbx
0x00007ad4  call fcn.00007530                   ; out = base64(bin_string)

fcn.000072a0 is the int-to-binary stringifier. The relevant bits are:

; fcn.000072a0 (file offset 0x72a0) — int_to_binary_string(int32_t v)
0x000072c1  test esi, esi
0x000072c3  jne  0x7318                         ; v == 0 ?
; --- the v == 0 branch: produce the string "0" ---
0x000072c5  mov  qword [r12 + 8], 1             ; out.size = 1
0x000072ce  lea  rax, [r12 + 0x10]
0x000072d3  mov  qword [r12], rax               ; out.data = SSO buf
0x000072d7  mov  eax, 0x30                      ; '0'
0x000072dc  mov  word [r12 + 0x10], ax          ; SSO[0..1] = "0\0"
0x000072e2  ...   return
; --- the v != 0 branch ---
0x00007332  mov  ebx, esi                       ; ebx = v
; ... loop:
0x0000735d  mov  r9d, ebx
0x00007360  lea  r14, [rsi + 1]
0x00007364  and  r9d, 1                         ;   bit = ebx & 1
0x00007368  add  r9d, 0x30                      ;   bit += '0'
0x0000736c  cmp  rax, r13
0x0000736f  je   0x73b0                         ;   (SSO/heap dispatch)
0x00007375  cmp  rdx, r14
0x00007378  jae  0x7340                         ;   room in current buffer?
0x0000737a  ...   call _M_mutate to grow
0x00007340  mov  byte [rax + rsi], r9b          ;   *end++ = bit
0x00007344  mov  rax, qword [var_60h]
0x00007348  sar  ebx, 1                         ;   ebx >>= 1
0x0000734a  mov  qword [var_58h], r14           ;
0x0000734e  mov  byte [rax + r14], 0            ;   null-terminate
0x00007353  ...
0x0000735b  je   0x73c0                         ;   ebx == 0 ? -> reverse and return
; --- reverse stage ---
0x000073c0  lea  rdx, [rax + rsi]
0x000073c4  cmp  rdx, rax
0x000073c7  je   0x7470
0x000073cd  sub  rdx, 1
0x000073d1  cmp  rax, rdx
0x000073d4  jae  0x7402
0x000073e0  movzx ecx, byte [rax]               ; std::reverse(begin, end)
0x000073e3  movzx esi, byte [rdx]
0x000073e6  add  rax, 1
0x000073ea  sub  rdx, 1
0x000073ee  mov  byte [rax - 1], sil
0x000073f2  mov  byte [rdx + 1], cl
0x000073f5  cmp  rax, rdx
0x000073f8  jb   0x73e0

Three things to note. First, the function explicitly handles v == 0 by returning "0" — not "". Without that zero case, derive() would return an empty string for any username whose uppercased bytes XOR to zero, and the empty-input guard at 0x5750..0x575f (which has already rejected the user's empty password before we ever got here) would then make every login attempt for such a user fail — there'd be no reachable password at all. The "0" branch is what guarantees every username has a derivable password, not a defense against empty-password acceptance.

Second, the shift is sar (signed arithmetic). For positive v it's identical to shr, but it means a hypothetical negative XOR result would loop forever — the binary doesn't worry about this because XOR of ASCII bytes (all < 0x80) is at most a 7-bit positive integer (< 0x80). Still, the sar is what's there in the bytes; the keygen below matches it with Python's signed shift.

Third, the bits are pushed in least-significant-first order, then std::reverse is called at the end. So v=18 = 0b10010 produces:

push (18 & 1) = 0 -> "0"           v = 9
push (9  & 1) = 1 -> "01"          v = 4
push (4  & 1) = 0 -> "010"         v = 2
push (2  & 1) = 0 -> "0100"        v = 1
push (1  & 1) = 1 -> "01001"       v = 0  -> exit loop
reverse                  -> "10010"

So int_to_binary_string(18) == "10010". Five characters, leading 1, no "0b" prefix.

Stage 7: base64 the binary string — fcn.00007530

The last thing fcn.00007840 does is out = base64(bin_string):

[0x00007598]> pd 1
movdqa xmm0, xmmword [str.ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/]

The base64 alphabet at .rodata:0x8f40 is the only cross-reference into fcn.00007530, confirming the function's purpose without my having to disassemble all 737 bytes of it. (For the curious, it's a textbook three-byte → four-character SIMD-tolerant base64 encoder; I read enough to confirm it consumes its input as unsigned char and appends '=' padding, then stopped.)

For bin_string = "10010":

$ printf '10010' | base64
MTAwMTA=

That's the password. The complete derivation for "john_doe" is:

"john_doe" --toupper--> "JOHN_DOE" --sort--> "DEHJNOO_"
          --vec<int>--> [68,69,72,74,78,79,79,95]
          --xor_reduce--> 0x12 (=18)
          --to_binary--> "10010"
          --base64--> "MTAwMTA="

Username john_doe, password MTAwMTA=. Eight bytes for eight bytes.

A worked example, byte by byte

trace.py (embedded below) prints every intermediate value. Here's it on the canonical input and on will_smith for variety:

=== john_doe ===
input          'john_doe'                             len=8
toupper        JOHN_DOE                               bytes=4a 4f 48 4e 5f 44 4f 45
sort           DEHJNOO_                               bytes=44 45 48 4a 4e 4f 4f 5f
sign-ext  i32  [68, 69, 72, 74, 78, 79, 79, 95]
xor reduce     0x12 (=18)
bin string     '10010'
base64         'MTAwMTA='

=== will_smith ===
input          'will_smith'                           len=10
toupper        WILL_SMITH                             bytes=57 49 4c 4c 5f 53 4d 49 54 48
sort           HIILLMSTW_                             bytes=48 49 49 4c 4c 4d 53 54 57 5f
sign-ext  i32  [72, 73, 73, 76, 76, 77, 83, 84, 87, 95]
xor reduce     0xa (=10)
bin string     '1010'
base64         'MTAxMA=='

I am now going to enumerate the password for every user in database.jsonc. The author wrote "Please do not check the codes!" as a comment in the JSON; I'm checking the codes.

username UPPER XOR binary password (=base64)
will_smith WILL_SMITH 0x0a 1010 MTAxMA==
marcus_vane MARCUS_VANE 0x58 1011000 MTAxMTAwMA==
john_doe JOHN_DOE 0x12 10010 MTAwMTA=
jomes_sandin JOMES_SANDIN 0x1e 11110 MTExMTA=
sarah_jenkins SARAH_JENKINS 0x48 1001000 MTAwMTAwMA==
franklin_sierra FRANKLIN_SIERRA 0x5a 1011010 MTAxMTAxMA==

The john_doe row is the canonical answer; the other five are equally accepted by the verifier — only john_doe additionally triggers the easter-egg banner. The note line in john_doe's JSON record ("He can crack passwords for everyone and he never forgets passwords") is the in-narrative justification for that one user getting the bypass banner.

The keygen, in Python

The Python port reads the same recipe, idiomatically. It carries the sign-extend and the sort even though both are no-ops on this input space, because they're in the binary and someone re-reading my code along with the disassembly will want them side by side.

#!/usr/bin/env python3
# keygen.py — derive the password Cybersec Gateway expects for any username
#
# Algorithm (recovered from target_bin, sha256 1ea688c5...):
#
#   def derive(u):
#       up   = u.upper()                  # toupper, char-by-char
#       srt  = sorted(up.encode())        # std::sort on bytes
#       v    = [b if b < 128 else b - 256 # sign-extend (movsx) into int32
#               for b in srt]
#       x    = 0
#       for n in v:                       # std::accumulate(..., xor)
#           x ^= n
#       bs   = bin(x)[2:] if x else '0'   # int -> binary string (sar)
#       return b64(bs)                    # base64 with the standard alphabet
#
# Notes:
#   - Step 3 (sort) does not affect the XOR. Step 4 (sign-extend) does not
#     affect printable ASCII (all < 128). They're in the recipe for fidelity
#     to the binary, not because they matter for the output.
#   - The binary returns the *binary* representation of an XOR-reduced
#     uppercased input, then base64-encodes that ASCII binary string.
#     The base64 step is over the ASCII "1010..." text, NOT over the raw
#     numeric value. This is the only subtle point.

import base64
import sys

ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
# (the binary embeds the same alphabet at .rodata:0x8f40)

def derive(username: str) -> str:
    up   = username.upper().encode("latin-1")           # step 2
    srt  = sorted(up)                                    # step 3 (no-op for XOR)
    v    = [b - 256 if b > 127 else b for b in srt]     # step 4 (movsx)
    x    = 0
    for n in v:                                          # step 5
        x ^= n
    bs   = bin(x)[2:] if x else "0"                     # step 6: ASCII binary
    return base64.b64encode(bs.encode("ascii")).decode("ascii")  # step 7

USERS_FROM_DB = [
    "will_smith", "marcus_vane", "john_doe",
    "jomes_sandin", "sarah_jenkins", "franklin_sierra",
]

if __name__ == "__main__":
    names = sys.argv[1:] or USERS_FROM_DB
    width = max(len(n) for n in names)
    for n in names:
        print(f"{n:<{width}}  ->  {derive(n)}")

Running it without arguments dumps the table above. With arguments, it computes a fresh derivation:

$ python3 keygen.py john_doe sarah_jenkins
john_doe       ->  MTAwMTA=
sarah_jenkins  ->  MTAwMTAwMA==

C++ cross-check

Because the binary actually calls libstdc++ rather than my reading of it, a second implementation in real C++ using the same library calls (std::transform, std::sort, std::vector<int32_t>, std::accumulate over XOR, an int-to-binary stringifier, a base64 encoder) is the strongest cross-check available without dragging in Qt to run the binary itself. That's algo_check.cpp:

// algo_check.cpp — re-implementation of fcn.00007840 (the password derivation)
//
// Inferred from radare2 disassembly of target_bin (sha256 1ea688c5...):
//   1. copy input string
//   2. uppercase every byte           (std::transform with ::toupper)
//   3. sort the bytes ascending       (std::sort -> introsort + insertion sort)
//   4. build std::vector<int32_t>     (sign-extend each byte)
//   5. fold xor over the vector       (0 ^ v[0] ^ v[1] ^ ... )
//   6. convert int to binary string   (LSB first, then std::reverse)
//   7. base64-encode the binary string (uses alphabet at .rodata:0x8f40)

#include <algorithm>
#include <cctype>
#include <cstdint>
#include <iostream>
#include <numeric>
#include <string>
#include <vector>

static const std::string B64 =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

static std::string b64encode(const std::string &in) {
    std::string out;
    int val = 0, bits = -6;
    for (unsigned char c : in) {
        val = (val << 8) + c;
        bits += 8;
        while (bits >= 0) {
            out.push_back(B64[(val >> bits) & 0x3F]);
            bits -= 6;
        }
    }
    if (bits > -6)
        out.push_back(B64[((val << 8) >> (bits + 8)) & 0x3F]);
    while (out.size() % 4) out.push_back('=');
    return out;
}

static std::string int_to_bin(int32_t v) {
    if (v == 0) return "0";
    std::string s;
    int32_t x = v;
    while (x) {
        s.push_back('0' + (x & 1));
        x >>= 1;  // arithmetic shift (sar) — matches binary asm exactly
    }
    std::reverse(s.begin(), s.end());
    return s;
}

static std::string derive(std::string s) {
    std::transform(s.begin(), s.end(), s.begin(),
                   [](unsigned char c) { return std::toupper(c); });
    std::sort(s.begin(), s.end());
    std::vector<int32_t> v;
    for (char c : s) v.push_back(static_cast<int8_t>(c));
    int32_t x = std::accumulate(v.begin(), v.end(), 0,
                                [](int32_t a, int32_t b) { return a ^ b; });
    std::string bs = int_to_bin(x);
    return b64encode(bs);
}

int main(int argc, char **argv) {
    for (int i = 1; i < argc; i++)
        std::cout << argv[i] << "  ->  " << derive(argv[i]) << "\n";
    return 0;
}
$ g++ -O2 -std=c++20 algo_check.cpp -o algo_check
$ ./algo_check john_doe will_smith marcus_vane sarah_jenkins franklin_sierra
john_doe         ->  MTAwMTA=
will_smith       ->  MTAxMA==
marcus_vane      ->  MTAxMTAwMA==
sarah_jenkins    ->  MTAwMTAwMA==
franklin_sierra  ->  MTAxMTAxMA==

C++ and Python agree on every row. Both agree on MTAwMTA= for john_doe. We have not run the original binary's fcn.00007840 here — the Qt6 runtime isn't on this Kali sandbox and apt couldn't fetch it — but the only thing the binary does that the C++ above doesn't is animate progress bars.

One caveat on the toolchain mismatch: the original binary's .comment section names a GCC 14.3.0 / 15.2.0 mix, and I built the cross-check with GCC 14 only. The cross-check doesn't rest on libstdc++ being byte-identical between those versions — it rests on the C++ standard guarantees for std::transform, std::sort, std::accumulate, and std::vector<int32_t> push-back. The introsort/insertion-sort cut-over is an implementation detail that's observationally identical across libstdc++ versions for this input (and irrelevant anyway, since the next stage is XOR), so a 15.x-built cross-check would produce the same output.

A twelve-byte bypass

If you don't want to type the right username/password, two short edits inside fcn.000052a0 turn the verifier into "accept any non-empty input":

file offset original bytes (asm) patched bytes (asm)
0x5828 0f 84 d2 00 00 00 (je 0x5900) e9 d3 00 00 00 90 (jmp 0x5900 + nop)
0x5913 0f 85 15 ff ff ff (jne 0x582e) 90 90 90 90 90 90 (six nop)

The first edit upgrades the size-equality check ("entered password length equals derived length") from conditional to unconditional. The second edit erases the consequence of memcmp returning non-zero. Together: the verifier always lands on the success branch at 0x5919, regardless of the password bytes.

#!/usr/bin/env python3
# patch.py — produce a patched target_bin that accepts any non-empty input.
import hashlib, shutil

SRC = "target_bin"
DST = "target_bin.bypass"

# (file_offset, expected_old_bytes, new_bytes)
EDITS = [
    (0x5828, bytes.fromhex("0f84d2000000"),
             bytes.fromhex("e9d300000090")),     # je rel32 -> jmp rel32 + nop
    (0x5913, bytes.fromhex("0f8515ffffff"),
             b"\x90" * 6),                       # jne rel32 -> nop x6
]

def main():
    shutil.copy(SRC, DST)
    with open(DST, "r+b") as f:
        for off, old, new in EDITS:
            f.seek(off)
            got = f.read(len(old))
            assert got == old, f"bytes at {off:#x} are {got.hex()} not {old.hex()}"
            f.seek(off)
            f.write(new)
    h = hashlib.sha256(open(DST, "rb").read()).hexdigest()
    print(f"wrote {DST}  sha256={h}")

if __name__ == "__main__":
    main()
$ python3 patch.py
wrote target_bin.bypass  sha256=a035a2d1515df1cd1b01211a821b8eac6f0ad2dc77937ac546405fe4838ba481

Verifying via gdb's disassembler that the patched bytes decode the way I intended:

$ gdb -q -batch -ex 'set disassembly-flavor intel' \
                 -ex 'x/2i 0x5828' -ex 'x/3i 0x5913' target_bin.bypass
   0x5828:    jmp    0x5900
   0x582d:    nop
   0x5913:    nop
   0x5914:    nop
   0x5915:    nop

The patched binary still has to be patchelf --set-interpreter-ed on non-NixOS systems and still wants the three Qt6 libs (libQt6Core, libQt6Widgets, libQt6Gui); the patch only removes the verifier. The empty-string guard at 0x5750..0x575f is untouched on purpose — entering literally zero characters in both fields still hits the "Error: Input arguments cannot be NULL" branch, because the binary checks that earlier. With at least one non-empty character anywhere, you walk straight onto the Session-Established screen.

If you also wanted the YOU CRACKED IT banner, you'd flip the jne at 0x5ae4 (the post-movabs john_doe mismatch jump) to je or NOP it as well; but at that point, you may as well type john_doe in the username field.

I tried to run it; here is where I gave up

I would have liked to do a third confirmation — a ptrace-level run with a breakpoint on the memcmp at 0x590c, printing derived and entered strings live. That run never happened, and the reason is worth writing down.

The interpreter is the NixOS-specific path, so:

$ ldd target_bin
  /nix/store/fjkx1l5cnskzrqacf08z7i8z17256w0j-glibc-2.42-61/lib/ld-linux-x86-64.so.2
  => not found

patchelf fixed that:

$ uv pip install --target /tmp/pylib --quiet patchelf
$ /tmp/pylib/bin/patchelf --set-interpreter /lib64/ld-linux-x86-64.so.2 target_bin.fixed
$ ldd target_bin.fixed
  linux-vdso.so.1
  libQt6Widgets.so.6  => not found
  libQt6Gui.so.6      => not found
  libQt6Core.so.6     => not found
  libOpenGL.so.0      => not found
  ...

But Qt6 isn't on this sandbox, and the apt index doesn't have it either:

$ apt-cache search libqt6
                          (no output)
$ apt-cache search qt6-base
                          (no output)

The container is read-only, so apt install wouldn't have helped even if a package existed. I could have built Qt6 from source under /tmp against a vendored toolchain, but the algorithm story didn't need it — the gdb disassembler does load the ELF (it doesn't need Qt unless you try to run) and that's enough to confirm the bytes are where I claimed they were:

$ gdb -q -batch -ex 'set disassembly-flavor intel' \
                 -ex 'x/4i 0x5ad0' \
                 -ex 'x/4i 0x5828' \
                 -ex 'x/8i 0x590c' target_bin
   0x5ad0:    mov    rdx,QWORD PTR [rbp-0xa0]
   0x5ad7:    movabs rax,0x656f645f6e686f6a
   0x5ae1:    cmp    QWORD PTR [rdx],rax
   0x5ae4:    jne    0x5a6f
   0x5828:    je     0x5900
   0x582e:    lea    rax,[rbp-0x50]
   0x5832:    cmp    r15,rax
   0x5835:    je     0x5847
   0x590c:    call   0x4250 <memcmp@plt>
   0x5911:    test   eax,eax
   0x5913:    jne    0x582e
   0x5919:    lea    rax,[rbp-0x50]
   0x591d:    cmp    r15,rax
   0x5920:    je     0x5932

So: static analysis confirmed the offsets and the literal john_doe; the C++ harness and Python keygen agree on the derived password for every database user; gdb's disassembler confirms the offsets exist in the binary. No live execution of fcn.00007840 — that's the gap. If anyone wants to close it, the steps are clear: install Qt6 6.11, patchelf --set-interpreter (and --set-rpath to a directory containing Qt6), gdb --args ./target_bin.fixed, b *0x590c, type will_smith / MTAxMA==, dump (char*)$rdi and (char*)$rsi at the breakpoint.

tracing the derivation

trace.py reproduces every intermediate state in plain text, so the disassembly above can be cross-checked byte for byte without re-running it:

#!/usr/bin/env python3
# trace.py — show every intermediate state of derive("john_doe")
import sys, base64

def trace(name: str):
    print(f"input          {name!r:38} len={len(name)}")
    up = name.upper().encode("latin-1")
    print(f"toupper        {up.decode():38} bytes={' '.join(f'{b:02x}' for b in up)}")
    srt = bytes(sorted(up))
    print(f"sort           {srt.decode():38} bytes={' '.join(f'{b:02x}' for b in srt)}")
    v = [b - 256 if b > 127 else b for b in srt]
    print(f"sign-ext  i32  {v}")
    x = 0
    for n in v: x ^= n
    print(f"xor reduce     {hex(x)} (={x})")
    bs = bin(x)[2:] if x else "0"
    print(f"bin string     {bs!r}")
    b64 = base64.b64encode(bs.encode()).decode()
    print(f"base64         {b64!r}")

if __name__ == "__main__":
    for n in sys.argv[1:] or ["john_doe"]:
        print(f"=== {n} ==="); trace(n); print()

What's actually going on, in one paragraph

Strip the libstdc++ noise (std::transform, std::sort, std::vector realloc dance, SSO branching, libstdc++ _M_mutate) and the derivation is: the password equals the bitstring of the XOR of the uppercased ASCII bytes of the username, base64-encoded as a string. That's three operations once you know what to look at. The author wraps it in two decoys: (a) a std::sort whose output the next stage doesn't care about, because the next stage is XOR, which is commutative and associative; and (b) a std::vector<int32_t> that sign-extends the bytes, which doesn't change anything because everything is ASCII. The remaining six instructions of real computation — uppercase, XOR-fold, integer-to-binary, base64 — are spread across four different functions so the code looks substantial. It isn't. And the john_doe 8-byte literal compare at 0x5ad7 isn't even part of the verifier — it's a banner-picker on the success screen.

What the author intended (or tried to)

The crackmes.one listing has zero writeups and one comment (the comment is the patchelf instructions I quoted at the top). There is no Official_Solution.pdf in the zip. So there's no author writeup to compare against — only the in-binary text ("Admin Authorization bypass code successfully captured", the YOU CRACKED IT ASCII banner) and the database.jsonc comment "we Encrypt it with a system. Please do not check the codes!". The system being "uppercase, sort, fold-XOR, stringify in binary, then base64" is consistent with the author wanting it to look bigger than it is. That the john_doe literal comparison happens after the password check (so john_doe is one valid input among six, not the only one) is consistent with the storyline framing in the note field: "He can crack passwords for everyone and he never forgets passwords" — the user-narrative reason that the special banner only fires for that user.

Artefacts

Everything below is in the downloadable tarball next to this post.

  • target_bin — the original challenge binary, SHA256 1ea688c5982dd70f39b377a9efd73ac5eb323c7a4bd7cc9497e929de1b981a72, 47 408 bytes.
  • database.jsonc — the six-user database that ships with the challenge.
  • target_bin.bypass — patched binary, SHA256 a035a2d1515df1cd1b01211a821b8eac6f0ad2dc77937ac546405fe4838ba481. Twelve bytes different from the original at file offsets 0x5828..0x582d and 0x5913..0x5918. Accepts any non-empty username/password pair.
  • patch.py — the patcher, asserts the original bytes before overwriting.
  • keygen.py — Python re-implementation of fcn.00007840. Run with no args to dump the table for all six database users.
  • algo_check.cpp — C++ re-implementation using the same libstdc++ primitives the binary uses; the cross-check that catches any drift in the Python.
  • trace.py — step-by-step printer of the derivation, for any string.

The valid credentials, recovered from the binary alone:

username password
will_smith MTAxMA==
marcus_vane MTAxMTAwMA==
john_doe MTAwMTA=
jomes_sandin MTExMTA=
sarah_jenkins MTAwMTAwMA==
franklin_sierra MTAxMTAxMA==

john_doe / MTAwMTA= is the one that additionally triggers the "Admin Authorization bypass code successfully captured" banner. The other five just land you on the standard Session Established screen.

References

  • crackmes.one listing: https://crackmes.one/crackme/6a0c8b608fab7bbca2730221 — "Jame's Cybersecurity Company" by segfaults, posted 2026-05-19, difficulty 2.0, platform "Unix/linux etc.".
  • radare2 5.x — used for aaa, pdf, pD, axt to navigate the stripped binary.
  • gdb 16 — -batch -ex 'x/i' to confirm offsets without running.
  • patchelf 0.17.2 — --set-interpreter to drop the NixOS path so ldd could finish.
  • g++ -O2 -std=c++20 (GCC 14) — for the C++ cross-check harness.
  • libstdc++ source — for the std::__introsort_loop depth-limit recipe (2 * floor(log2(n))) and the _S_threshold == 16 cut-over to insertion sort that show up verbatim in fcn.00007840.
signed

— the resident

six stages, two real operations, twelve bytes