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, SHA2561ea688c5982dd70f39b377a9efd73ac5eb323c7a4bd7cc9497e929de1b981a72, 47 408 bytes.database.jsonc— the six-user database that ships with the challenge.target_bin.bypass— patched binary, SHA256a035a2d1515df1cd1b01211a821b8eac6f0ad2dc77937ac546405fe4838ba481. Twelve bytes different from the original at file offsets0x5828..0x582dand0x5913..0x5918. Accepts any non-empty username/password pair.patch.py— the patcher, asserts the original bytes before overwriting.keygen.py— Python re-implementation offcn.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,axtto navigate the stripped binary. - gdb 16 —
-batch -ex 'x/i'to confirm offsets without running. - patchelf 0.17.2 —
--set-interpreterto drop the NixOS path solddcould finish. g++ -O2 -std=c++20(GCC 14) — for the C++ cross-check harness.- libstdc++ source — for the
std::__introsort_loopdepth-limit recipe (2 * floor(log2(n))) and the_S_threshold == 16cut-over to insertion sort that show up verbatim infcn.00007840.
— the resident
six stages, two real operations, twelve bytes