Hello Navi

Tech, Security & Personal Notes

A secret flag is safely hidden away within client-side scripts. We were told JavaScript is an insecure medium for secret storage, so we decided to use C++ instead.

Analysis

The following radare2 transcript lists only the two exports and first two hashes relevant to the analysis; it is an excerpt, not a runnable solver. The solver below retains all 40 hashes. The original WASM, character-search run and final candidate are unavailable, so only table completeness and Python syntax are checked here.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
❯ r2 flag.wasm
[0x0000019a]> iE
[Exports]
137 0x0000a343 0x0000a343 GLOBAL FUNC 12 CompareFlag
138 0x0000a34f 0x0000a34f GLOBAL FUNC 17 CompareFlagIndex

[0x0000019a]> iz
[Strings]
nth paddr vaddr len size section type string
―――――――――――――――――――――――――――――――――――――――――――――――――――――――
92 0x000098d9 0x000098d9 24 25 data ascii OrpheanBeholderScryDoubt
96 0x00009979 0x00009979 64 65 data ascii ./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789
97 0x000099ba 0x000099ba 60 61 data ascii $2b$12$uAfq9EI1EoIC316VgA3azeOyogkKzG4zz2kF8M.l.D4h4nT4WsidK
98 0x000099f7 0x000099f7 60 61 data ascii $2b$12$NmhDm/LZzjanlv6xuHCsVe8JJNlvEb3uYUEQ03abPIlCuTE6qtrT.

Obviously the flag is stored as a series of bcrypt hashes, which are all prefixed with $2b$12$.

Cracking the Flag

If we test single characters, we can see that the flag start with 2, but the next character is unknown.

Since the hashes are cumulative, each one validates the entire flag prefix up to that point. We can recover the flag by brute-forcing each character in sequence and appending it to the previously discovered string.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import bcrypt
import string

hashes = [
b"$2b$12$uAfq9EI1EoIC316VgA3azeOyogkKzG4zz2kF8M.l.D4h4nT4WsidK",
b"$2b$12$NmhDm/LZzjanlv6xuHCsVe8JJNlvEb3uYUEQ03abPIlCuTE6qtrT.",
b"$2b$12$8OhK6ZPoSuBujRxR3pz4g.vp6LvTqJe/NJZZHTHtOPkdIbDb1GDKS",
b"$2b$12$PhFiPd28yDeXdZaJfUDjTOiAUQtpBJ2AjD5pFIG7CtUXQtWECGpre",
b"$2b$12$DfQJicmUWZQ0EVGKxdQEN.yCj3s4o6GyMraqt514d3DRkAqH8PYq6",
b"$2b$12$JikQohCsuFN6DO7q9ZHCTeHuzL3/Hb3diMYJsUGgAI4AH64x9jtyO",
b"$2b$12$4C2jJ0QxCKdqyrBTIhqEGeeq1IMOZJs7DllwqtMWbp.rM7BPsbDwG",
b"$2b$12$FI45z3VbyCC4Bb5rVJsLb./Od6aSnT8tHIPkwmZCgGNNrXwpJqkO6",
b"$2b$12$tFkj/QdzBVsk8XjjjH91eefYY/lx6YX/4lnB9T/GKSIvpmx7mEEG2",
b"$2b$12$Il.BDj/qxIkgROEZN4/Te.QJawuPW18MHU1hVQzNIC9SW7H.Mo9.2",
b"$2b$12$3UOGifrFe0iGGh4sSWx1JeB919LDApovzwbYIQqniIFVE3/mgEFkW",
b"$2b$12$5voYYJHxGJVy3ITneNhk/.XbcfOKDDnMHiS2CTri0ncFQ/jUgND.e",
b"$2b$12$cDvS2AqrJ72gvUP5wSnjSOqdsFIKcsGI863NxXgdedYzMV0YzOZmW",
b"$2b$12$pIcJfpN7L0SGQtA/4bcX.ewqrSkeUzCeq4mrjHCzhwQKB2LTc4tJe",
b"$2b$12$4xjImCcvXpgG.WFwjlryEONm4gFy3/O2VSCsrL1lX38f0XDPKc6Hm",
b"$2b$12$gIWlY5GubfJ1kIhMEO9GnuTbalD8aPc6ECdNIq.4Vjx6S38nKLG8S",
b"$2b$12$9UpsAlXYVpPw4B93u2WBm.Ve0JMqdkQ0wxvuAPqnXmtzjmvXm0hea",
b"$2b$12$QqTL8meoLdWMnipKwuRoC.d9ei6TU2ev1Ggu0VsC2gLGMfF7QWOPi",
b"$2b$12$8M.Z95IrSP64adu2LiOhzO4vhtmfjBx45Pp.FJsq4Tqe/t5GaPeA2",
b"$2b$12$GNWfLovpvpMcoK89QdZzt.u8XibRtwo0aFFnUSBcqs0SjocL6hgVS",
b"$2b$12$mLzTYglkEg3iqusfz8lOOuH548ezA.mgfr8pYI7cd3ozU8aPJBhAC",
b"$2b$12$6GTg.qAyDUQorM1BwcIXRe7Ab.L3ZXqJhI0xg2G.OtCVf5W1BH7zu",
b"$2b$12$Nxd1aKxcgV4s51dN5nc2puAtG8J6asT8vcvB0kfWhcfYp868nza7.",
b"$2b$12$Z2/4n8JEXI19ZFL7A4ojEOiSbfAeV3KZj5Nc0.Uu6sXG6KHvtPCLi",
b"$2b$12$AEiJfo2eTPnTCU.NL2jJeOifcw/TOAZaOLjMAPKEdfJmgdQy/WoYC",
b"$2b$12$8pA4oDi3uovODvOuf2GrteqltIOhDUH/AI07H1NrvCoA5AvL9vKJe",
b"$2b$12$Kke80penOJ8l7/EBoDZCWufdwdWju/Twb6.9DSm498.I922qNBfBK",
b"$2b$12$xOcqWzPSMN3VgbsmEmZbYe98NBK1Qxpp6fAZNYCEiU/Lw5vsbIOz.",
b"$2b$12$OnXeQsiQyBpIZzciVGSkUuBwcr62OoirL8Ebb9QczH7AAFdIsrbxi",
b"$2b$12$3c8V9ss5ATsQkkz0ZUg2T.x0qCBszvuetJPX.vm9XPgsGBwhedfhy",
b"$2b$12$xVrrb1qPs3mHX2kp6vo10e8zsUqDxXxlmptJnFBT/5YVDeSGAJsty",
b"$2b$12$BA5vnPd.oxWN4BEn6PybEeXgWYrX02k9rHXLnDAiDedUilCuiv2jy",
b"$2b$12$7p6s4NoKXsjqD/0wnuO2b.2ux70dPNcN5wBYccuzz8vm1ZZ9iPPLu",
b"$2b$12$oXuFS3O5Td3knq2gRyf5XOhwj1.IYOWQ9fSvGY05YU0MwizIm18Ru",
b"$2b$12$l3wvb/fiYbkzoqWv1.ulMuQPTn6xP67D0/YkjNzwJi1bK30qJAZWu",
b"$2b$12$3eFpVZJh6TfrnbE.hdfitu8UiqLei7u2vEjFPecu6O5FqNqyOYOs.",
b"$2b$12$XtrkQGAyvRcIdCtW4AK9/.9oSlP2rAwE.KNk5f2sKuyhhDNzIAvzC",
b"$2b$12$zrsIpC4WnPVjcCRODlRXT.IDPIZwBEP2VwTv.q5/DIfCpdD44zoam",
b"$2b$12$Lr3UiwLPab6yEw.TERhNAu1/qlQelYuqmF/Wcg3UtrzslAzrf3/di",
b"$2b$12$RtpdIcXU8hH8pnDGQHCupu5l2mw872X6SFamb20w9A.sieVEk7Xba",
]

charset = string.printable.strip()
flag = ""

for i, h in enumerate(hashes):
print(f"[*] Cracking index {i:2d}... ", end="", flush=True)
for c in charset:
if bcrypt.checkpw((flag + c).encode(), h):
flag += c
print(f"Found: {c} | Current: {flag}")
break
else:
print("Failed to crack.")
break

print(f"\n[+] Flag: {flag}")

Result

1
[+] 247CTF{<flag-redacted>}
247CTF{[flag redacted]}

We stored the flag within this binary, but made a few errors when trying to print it. Can you make use of these errors to recover the flag?

Overview

1
2
❯ file flag_errata.exe
flag_errata.exe: PE32+ executable for MS Windows 5.02 (console), x86-64 (stripped to external PDB), 9 sections

A PE64 binary (GCC 7.3 / MinGW-w64) with a monstrous 53KB main function. It prompts for a 40-character password, validates each character through error-code accumulation, then returns -247 on any mismatch.

The core pattern repeats 40 times:

1
2
3
4
5
6
7
8
9
sub_401560();                    // intentionally failing WinAPI call
LastError = GetLastError(); // harvest the error code
sub_40159B();
v5 = GetLastError() + LastError; // accumulate
sub_4015D6();
v6 = GetLastError() + v5;
// ... N calls total ...
if ( Buffer[i] != (int)(accumulated) % 126 )
return -247; // failure — 247CTF signature

The password is never stored anywhere. It's implicitly encoded by the count and sequence of WinAPI calls whose error codes reconstruct each character.

The Error Code Cycle

13 functions form a fixed cycle, each designed to fail deterministically:

# Function Address API Call Expected Error Code
0 errgen_open_247ctf_dll_system32 0x401560 CreateFileA("C:\Windows\System32\247CTF.dll", ...) ERROR_FILE_NOT_FOUND 2
1 errgen_open_247ctf_dll_nested 0x40159B CreateFileA("C:\247CTF\247CTF\247CTF.dll", ...) ERROR_PATH_NOT_FOUND 3
2 errgen_open_user32_dll_readwrite 0x4015D6 CreateFileA("user32.dll", GENERIC_RW, exclusive) ERROR_SHARING_VIOLATION 32
3 errgen_getthreadtimes_null 0x401611 GetThreadTimes(0, 0, 0, 0, 0) ERROR_INVALID_HANDLE 6
4 errgen_findfiles_exhaust 0x401634 FindFirstFile("C:\*") + loop FindNextFile ERROR_NO_MORE_FILES 18
5 errgen_virtualqueryex_null 0x401674 VirtualQueryEx(proc, NULL, NULL, 8) ERROR_BAD_LENGTH 24
6 errgen_open_cmdexe_twice 0x401697 CreateFileA("cmd.exe") x2 (exclusive) ERROR_SHARING_VIOLATION 32
7 errgen_open_user32_invalid_create 0x401704 CreateFileA("user32.dll", ..., dwCreation=0) ERROR_INVALID_PARAMETER 87
8 errgen_setconsoledisplaymode 0x40173F SetConsoleDisplayMode(FULLSCREEN) Varies by OS ~87
9 errgen_open_247ctf_colon 0x401764 CreateFileA("247CTF:", ...) ERROR_INVALID_NAME 123
10 errgen_loadlibrary_fake_dll 0x4017A2 LoadLibraryA("C:\247CTF.DLL") ERROR_MOD_NOT_FOUND 126
11 errgen_createmutex_twice 0x4017B0 CreateMutexA("247CTF") x2 ERROR_ALREADY_EXISTS 183
12 errgen_createprocess_user32 0x4017DF CreateProcessA("user32.dll" as exe) ERROR_BAD_EXE_FORMAT 193

Each character block calls \(N\) consecutive functions from this cycle, accumulates the error codes, then checks sum % 126 against the input.

Algebraic Solution

The key insight: since the error codes cycle deterministically, we can bypass the Windows environment entirely and solve this as pure modular arithmetic.

I use Arch BTW.

Let:

  • \(S = \sum_{k=0}^{12} e_k\), the sum of one full cycle (all 13 error codes)
  • \(P(r) = \sum_{k=0}^{r-1} e_k\), the partial sum of the first \(r\) codes

For character \(i\) with \(n_i\) total API calls:

\[q_i = \left\lfloor \frac{n_i}{13} \right\rfloor, \qquad r_i = n_i \bmod 13\]

\[\text{password}[i] \equiv q_i \cdot S + P(r_i) \pmod{126}\]

The flag format 247CTF{<flag-redacted>} gives us 8 known characters for free. Three of them — blocks 2, 3, 6 — share the same remainder \(r = 9\): same position in the error-code cycle, different lap counts. Shared \(P(9)\) cancels on subtraction.

Blocks 2, 3, 6 correspond to 7 (ASCII \(55\), \(q = 16\)), C (\(67\), \(q = 10\)), { (\(123\), \(q = 24\)):

\[\begin{aligned} 16S + P(9) &\equiv 55 \pmod{126} && \quad (1) \\ 10S + P(9) &\equiv 67 \pmod{126} && \quad (2) \\ 24S + P(9) &\equiv 123 \pmod{126} && \quad (3) \end{aligned}\]

Subtracting \((2)\) from \((1)\) eliminates \(P(9)\):

\[\begin{aligned} (16 - 10)\,S &\equiv 55 - 67 \pmod{126} \\ 6S &\equiv -12 \equiv 114 \pmod{126} \end{aligned}\]

Since \(\gcd(6, 126) = 6\) divides \(114\), we may divide the entire congruence by \(6\) (reducing the modulus to \(126/6 = 21\)):

\[S \equiv 19 \pmod{21} \qquad \Longrightarrow \qquad S \in \{19,\; 40,\; 61,\; 82,\; 103,\; 124\}\]

Similarly, \((3) - (1)\) yields a second constraint:

\[\begin{aligned} 8S &\equiv 68 \pmod{126} \\ 4S &\equiv 34 \pmod{63} && \quad (\text{dividing by } \gcd(2, 126) = 2) \end{aligned}\]

To isolate \(S\), multiply both sides by the modular inverse \(4^{-1} \equiv 16 \pmod{63}\), since \(4 \times 16 = 64 \equiv 1\):

\[S \equiv 16 \times 34 = 544 \equiv 40 \pmod{63}\]

Intersecting via CRT: \(40 \bmod 21 = 19\;\checkmark\), so both congruences agree.

\[\boxed{\,S = 40\,}\]

Back-substituting into \((1)\):

\[\begin{aligned} 16 \times 40 + P(9) &\equiv 55 \pmod{126} \\ 640 + P(9) &\equiv 55 \pmod{126} \\ 10 + P(9) &= 55 && \quad (640 \bmod 126 = 10) \\ P(9) &= 45 \end{aligned}\]

Same process with the remaining known characters fills all partial sums:

\(P(r)\) Value Source
\(P(3)\) 10 '2' (ASCII 50)
\(P(4)\) 16 \(e_3 = 6\)
\(P(5)\) 34 \(e_4 = 18\)
\(P(6)\) 58 '4' (ASCII 52)
\(P(7)\) 90 'T' (ASCII 84)
\(P(9)\) 45 '7' (ASCII 55)
\(P(10)\) 42 'F' (ASCII 70)
\(P(12)\) 99 \(e_{10}, e_{11}\)

With \(S\) and all \(P(r)\) determined, every character computes directly:

Block Calls \(q\) \(r\) Calculation Char
0 16 1 3 \((40+10) \bmod 126\) 2
7 191 14 9 \((560+45) \bmod 126\) e
9 109 8 5 \((320+34) \bmod 126\) f
15 285 21 12 \((840+99) \bmod 126\) 9
20 298 22 12 \((880+99) \bmod 126\) a
39 35 2 9 \((80+45) \bmod 126\) }

All 40 characters land in the valid charset — 247CTF{<flag-redacted>} suffix — confirming the solution without ever running the binary.

Character-by-Character Breakdown

Block Calls q r Formula Char
0 16 1 3 \((1 \times 40 + 10) \bmod 126 = 50\) 2
1 45 3 6 \((3 \times 40 + 58) \bmod 126 = 52\) 4
2 217 16 9 \((16 \times 40 + 45) \bmod 126 = 55\) 7
3 139 10 9 \((10 \times 40 + 45) \bmod 126 = 67\) C
4 46 3 7 \((3 \times 40 + 90) \bmod 126 = 84\) T
5 101 7 10 \((7 \times 40 + 42) \bmod 126 = 70\) F
6 321 24 9 \((24 \times 40 + 45) \bmod 126 = 123\) {
7 191 14 9 \((14 \times 40 + 45) \bmod 126 = 101\) e
8 280 21 7 \((21 \times 40 + 90) \bmod 126 = 48\) 0
9 109 8 5 \((8 \times 40 + 34) \bmod 126 = 102\) f
10 19 1 6 \((1 \times 40 + 58) \bmod 126 = 98\) b
11 256 19 9 \((19 \times 40 + 45) \bmod 126 = 49\) 1
12 109 8 5 \((8 \times 40 + 34) \bmod 126 = 102\) f
13 241 18 7 \((18 \times 40 + 90) \bmod 126 = 54\) 6
14 215 16 7 \((16 \times 40 + 90) \bmod 126 = 100\) d
15 285 21 12 \((21 \times 40 + 99) \bmod 126 = 57\) 9
16 17 1 4 \((1 \times 40 + 16) \bmod 126 = 56\) 8
17 215 16 7 \((16 \times 40 + 90) \bmod 126 = 100\) d
18 217 16 9 \((16 \times 40 + 45) \bmod 126 = 55\) 7
19 215 16 7 \((16 \times 40 + 90) \bmod 126 = 100\) d
20 298 22 12 \((22 \times 40 + 99) \bmod 126 = 97\) a
21 285 21 12 \((21 \times 40 + 99) \bmod 126 = 57\) 9
22 280 21 7 \((21 \times 40 + 90) \bmod 126 = 48\) 0
23 215 16 7 \((16 \times 40 + 90) \bmod 126 = 100\) d
24 217 16 9 \((16 \times 40 + 45) \bmod 126 = 55\) 7
25 12 0 12 \((0 \times 40 + 99) \bmod 126 = 99\) c
26 38 2 12 \((2 \times 40 + 99) \bmod 126 = 53\) 5
27 12 0 12 \((0 \times 40 + 99) \bmod 126 = 99\) c
28 191 14 9 \((14 \times 40 + 45) \bmod 126 = 101\) e
29 109 8 5 \((8 \times 40 + 34) \bmod 126 = 102\) f
30 16 1 3 \((1 \times 40 + 10) \bmod 126 = 50\) 2
31 217 16 9 \((16 \times 40 + 45) \bmod 126 = 55\) 7
32 12 0 12 \((0 \times 40 + 99) \bmod 126 = 99\) c
33 16 1 3 \((1 \times 40 + 10) \bmod 126 = 50\) 2
34 109 8 5 \((8 \times 40 + 34) \bmod 126 = 102\) f
35 45 3 6 \((3 \times 40 + 58) \bmod 126 = 52\) 4
36 217 16 9 \((16 \times 40 + 45) \bmod 126 = 55\) 7
37 285 21 12 \((21 \times 40 + 99) \bmod 126 = 57\) 9
38 17 1 4 \((1 \times 40 + 16) \bmod 126 = 56\) 8
39 35 2 9 \((2 \times 40 + 45) \bmod 126 = 125\) }

Anti-Analysis

  • TLS Callbacks: Two callbacks (0x401C50, 0x401C20) execute before main — a common anti-debug vector.
  • 53KB of bloat: Up to 321 API calls per character, making manual static analysis impractical without scripting.
  • Environment sensitivity: Functions like CreateProcessA and SetConsoleDisplayMode return different error codes under a debugger, making dynamic analysis a trap. The algebraic approach sidesteps this entirely by reasoning at the mathematical level, independent of the Windows runtime.
247CTF{[flag redacted]}

Why waste time creating multiple functions, when you can just use one? Can you find the path to the flag in this angr-y binary?

Challenge Overview

The challenge provides a 32-bit ELF executable. The goal is to find the correct input that leads to the flag.

1
2
❯ file angr-y_binary
angr-y_binary: ELF 32-bit LSB executable, Intel i386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=611e939f262f927b8515162283d36476df2d3244, not stripped

Analysis

Using radare2 to inspect the disassembly, we identify three key functions: print_flag, no_flag, and maybe_flag.

Disassembly

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
     ;-- print_flag:
0x08048596 55 push ebp
0x08048597 89e5 mov ebp, esp
...
0x080485db e830feffff call sym.imp.fgets ;[3]
0x080485e0 83c410 add esp, 0x10
0x080485e3 83ec0c sub esp, 0xc
0x080485e6 8d45b4 lea eax, [ebp - 0x4c]
0x080485e9 50 push eax
0x080485ea e841feffff call sym.imp.puts ;[4]
...

;-- no_flag:
0x08048609 55 push ebp
0x0804860a 89e5 mov ebp, esp
0x0804860c e8d0005e00 call sym.__x86.get_pc_thunk.ax ;[6]
0x08048611 05ef195e00 add eax, 0x5e19ef
0x08048616 c780300000.. mov dword [eax + 0x30], 0
0x08048620 90 nop
0x08048621 5d pop ebp
0x08048622 c3 ret

;-- maybe_flag:
0x08048623 55 push ebp
0x08048624 89e5 mov ebp, esp
0x08048626 53 push ebx
0x08048627 83ec04 sub esp, 4
0x0804862a e8b2005e00 call sym.__x86.get_pc_thunk.ax ;[6]
0x0804862f 05d1195e00 add eax, 0x5e19d1
0x08048634 8b9030000000 mov edx, dword [eax + 0x30]
0x0804863a 85d2 test edx, edx
┌─< 0x0804863c 7407 je 0x8048645
│ 0x0804863e e853ffffff call sym.print_flag ;[7]
┌──< 0x08048643 eb14 jmp 0x8048659
│└─> 0x08048645 83ec0c sub esp, 0xc
...

The logic suggests we need to reach print_flag while avoiding the paths that lead to no_flag.

Solution

Since the binary's path to the flag is complex, we use angr to perform symbolic execution and find the correct input.

Solver Script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import angr
import sys

def solve(bin_path):
project = angr.Project(bin_path)

# Initialize the state at entry
initial_state = project.factory.entry_state(
add_options = {
angr.options.SYMBOL_FILL_UNCONSTRAINED_MEMORY,
angr.options.SYMBOL_FILL_UNCONSTRAINED_REGISTERS
}
)

simulation = project.factory.simgr(initial_state)

# Explore searching for the print_flag function and avoiding no_flag
# print_flag: 0x08048596
# no_flag: 0x8048609
simulation.explore(find=0x08048596, avoid=0x8048609)

if simulation.found:
solution_state = simulation.found[0]
print(f"Found solution: {solution_state.posix.dumps(sys.stdin.fileno()).decode()}")
else:
raise Exception('Could not find the solution')

if __name__ == "__main__":
solve('./angr-y_binary')

Execution

Running the script gives us the password:

1
2
❯ python solve.py
wgIdWOS6Df9sCzAfiK

Connecting to the server with the found password:

1
2
3
❯ nc 0c4c28058a1f7a2f.247ctf.com 50230
Enter a valid password:
wgIdWOS6Df9sCzAfiK

The returned flag belongs to the launched service instance; no cross-instance invariance is established. Record the generation/retrieval method and use the current instance response instead of a fixed-answer spoiler.

References