LIGA CTF: Reverse Engineering [W1]
Logon Test
This is to verify if you have successfully log in.
The flag format for this CTF can be one of the followings:
- liga{xxx}
- OWASPKL{xxx}
- ligactf{xxx}
Each challenge description will explicitly mention which flag format to use.
Submit OWASPKL{FR33_FL4G} to earn free point of this challenge.
Flag: OWASPKL{FR33_FL4G}
Find the C2 Server
This APK file is malicious. It secretly talks to a C2 Server. Identify the C2 Server address, and find the flag.
Flag format: OWASPKL{xxx}
On decompiled source of java using jadx. On MainActivity, a string was called
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* renamed from: String$val-d1$try$fun-$anonymous$$arg-5$call-thread$fun-backdoorC2$class-MainActivity, reason: not valid java name */
private static String f98x506ff06 = "https://appsecmy.com/";
/* renamed from: String$val-d2$try$fun-$anonymous$$arg-5$call-thread$fun-backdoorC2$class-MainActivity, reason: not valid java name */
private static String f99xcc12e607 = "pages/liga-ctf-2026";
/* renamed from: String$arg-0$call-setRequestMethod$try$fun-$anonymous$$arg-5$call-thread$fun-backdoorC2$class-MainActivity, reason: not valid java name */
private static String f88xf204d679 = "POST";
/* renamed from: Boolean$arg-0$call-setDoOutput$try$fun-$anonymous$$arg-5$call-thread$fun-backdoorC2$class-MainActivity, reason: not valid java name */
private static boolean f53xb9b2a8a0 = true;
/* renamed from: String$arg-0$call-setRequestProperty$try$fun-$anonymous$$arg-5$call-thread$fun-backdoorC2$class-MainActivity, reason: not valid java name */
private static String f89xd700fed = "Content-Type";
/* renamed from: String$arg-1$call-setRequestProperty$try$fun-$anonymous$$arg-5$call-thread$fun-backdoorC2$class-MainActivity, reason: not valid java name */
private static String f97x3d2743ee = "application/json";
C2 URL is constructed by concatenating two string constants:
d1=https://appsecmy.com/d2=pages/liga-ctf-2026
So the C2 is: https://appsecmy.com/pages/liga-ctf-2026
The app POSTs exfiltrated data (stolen Telegram session content) to that endpoint as JSON, with the Content-Type: application/json header.
when reconstruct those C2 and curl it
1
2
3
┌──(myenv)(osiris㉿ALICE)-[~]
└─$ curl https://appsecmy.com/pages/liga-ctf-2026 | grep "OWASPKL{"
<!-- OWASPKL{https://chat.whatsapp.com/KAdpus4R0pb895ulC2jo8p} This is the FL4G. But feel free to join our Community Group-->
Flag: OWASPKL{https://chat.whatsapp.com/KAdpus4R0pb895ulC2jo8p}
unpackme0 - Easy
With that out of the way, your first task is to identify the packer used for this binary, and unpack it. Provide the md5 hash of the unpacked file as your flag. Example: OWASPKL{23ac7b66851387b96a20672b5c0dc856}
directly
1
upx -d <file>
Solving
1
2
3
PS C:\Users\os1ris\Desktop > certutil -hashfile unpackme0 md5
MD5 hash of unpackme0:
1cc6a3b62cac36ab18e0c4685a7f4bdf
Flag: OWASPKL{1cc6a3b62cac36ab18e0c4685a7f4bdf}
unpackme1 - Medium
Well done, by now you should hopefully understand more about packed binaries. Things won’t be as straightforward anymore though. A simple anti unpacking technique was applied to this packed binary.
Your next task is the same: identify the packer used for this binary, and unpack it. Instead of getting the file hash, the flag is hidden in the unpacked file as a string. Format: OWASPKL{Im_A_Flag}
Observation
The file mentions VQY packer which seems to be a custom/fictional packer for the CTF. But I also see UPX markers (“UPX!” string appears)
The binary has “VQY!” markers and “UPX!” markers in the text. The VQY packer is likely just a modified UPX with the magic bytes changed. we can change the magic bytes of VQY! into UPX! and try to manually fix them.
1
2
3
data = open('/os1ris/unpackme1','rb').read()
patched = data.replace(b'VQY!', b'UPX!')
open('/os1ris/patched', 'wb').write(patched)
Unpack
and then try unpack it back using UPX
1
2
3
4
5
6
7
8
9
10
$ upx -d <file>
Ultimate Packer for eXecutables
Copyright (C) 1996 - 2024
UPX 4.2.2 Markus Oberhumer, Laszlo Molnar & John Reiser Jan 3rd 2024
File size Ratio Format Name
-------------------- ------ ----------- -----------
24527 <- 6596 26.89% linux/amd64 unpackme1_patch
Unpacked 1 file.
directly string the file
1
2
3
$ strings unpackme1_patch | grep -i "OWASPKL”
Format: OWASPKL{Im_A_Flag}
OWASPKL{Unpackm3_4mat3ur0923257}
Flag: OWASPKL{Unpackm3_4mat3ur0923257}
unpackme2 - Medium
Your next task is the same: identify the packer used for this binary, and unpack it. The flag for this challenge is made up of two parts: The name of the packer (in all caps), and the flag string in the binary. The flag string does NOT follow the OWASPKL{} flag format! It’s simply a string in l33tspeak hidden in the program. The two parts are separated with an underscore (_).
Format: OWASPKL{[PACKERNAME]_[EXAMPLEFLAG]} Example: OWASPKL{MYPACKER_3xampl3Fl4g}
Dynamic
the packer was ASPack when checking on Detect It Easy. Using xdbg32. set breakpoint on VirtualProtect. Watch the EAX and follow the dump will able to see the leetspeak code.
Flag: OWASPKL{ASPACK_Unpackm3C0mpl3te}
Lockbox
Decompile
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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
Disassembly of section .init:
000000000040033c <.init>:
40033c: f3 0f 1e fa endbr64
400340: 48 83 ec 08 sub rsp,0x8
400344: 48 8b 05 95 2c 00 00 mov rax,QWORD PTR [rip+0x2c95] # 402fe0 <putc@plt+0x2c30>
40034b: 48 85 c0 test rax,rax
40034e: 74 02 je 400352 <puts@plt-0x1e>
400350: ff d0 call rax
400352: 48 83 c4 08 add rsp,0x8
400356: c3 ret
Disassembly of section .plt:
0000000000400360 <puts@plt-0x10>:
400360: ff 35 8a 2c 00 00 push QWORD PTR [rip+0x2c8a] # 402ff0 <putc@plt+0x2c40>
400366: ff 25 8c 2c 00 00 jmp QWORD PTR [rip+0x2c8c] # 402ff8 <putc@plt+0x2c48>
40036c: 0f 1f 40 00 nop DWORD PTR [rax+0x0]
0000000000400370 <puts@plt>:
400370: ff 25 8a 2c 00 00 jmp QWORD PTR [rip+0x2c8a] # 403000 <putc@plt+0x2c50>
400376: 68 00 00 00 00 push 0x0
40037b: e9 e0 ff ff ff jmp 400360 <puts@plt-0x10>
0000000000400380 <strlen@plt>:
400380: ff 25 82 2c 00 00 jmp QWORD PTR [rip+0x2c82] # 403008 <putc@plt+0x2c58>
400386: 68 01 00 00 00 push 0x1
40038b: e9 d0 ff ff ff jmp 400360 <puts@plt-0x10>
0000000000400390 <printf@plt>:
400390: ff 25 7a 2c 00 00 jmp QWORD PTR [rip+0x2c7a] # 403010 <putc@plt+0x2c60>
400396: 68 02 00 00 00 push 0x2
40039b: e9 c0 ff ff ff jmp 400360 <puts@plt-0x10>
00000000004003a0 <strcmp@plt>:
4003a0: ff 25 72 2c 00 00 jmp QWORD PTR [rip+0x2c72] # 403018 <putc@plt+0x2c68>
4003a6: 68 03 00 00 00 push 0x3
4003ab: e9 b0 ff ff ff jmp 400360 <puts@plt-0x10>
00000000004003b0 <putc@plt>:
4003b0: ff 25 6a 2c 00 00 jmp QWORD PTR [rip+0x2c6a] # 403020 <putc@plt+0x2c70>
4003b6: 68 04 00 00 00 push 0x4
4003bb: e9 a0 ff ff ff jmp 400360 <puts@plt-0x10>
Disassembly of section .text:
00000000004003c0 <.text>:
4003c0: f3 0f 1e fa endbr64
4003c4: 31 ed xor ebp,ebp
4003c6: 49 89 d1 mov r9,rdx
4003c9: 5e pop rsi
4003ca: 48 89 e2 mov rdx,rsp
4003cd: 48 83 e4 f0 and rsp,0xfffffffffffffff0
4003d1: 50 push rax
4003d2: 54 push rsp
4003d3: 45 31 c0 xor r8d,r8d
4003d6: 31 c9 xor ecx,ecx
4003d8: 48 c7 c7 ce 05 40 00 mov rdi,0x4005ce
4003df: ff 15 f3 2b 00 00 call QWORD PTR [rip+0x2bf3] # 402fd8 <putc@plt+0x2c28>
4003e5: f4 hlt
4003e6: 66 2e 0f 1f 84 00 00 cs nop WORD PTR [rax+rax*1+0x0]
4003ed: 00 00 00
4003f0: f3 0f 1e fa endbr64
4003f4: c3 ret
4003f5: 66 2e 0f 1f 84 00 00 cs nop WORD PTR [rax+rax*1+0x0]
4003fc: 00 00 00
4003ff: 90 nop
400400: b8 30 30 40 00 mov eax,0x403030
400405: 48 3d 30 30 40 00 cmp rax,0x403030
40040b: 74 13 je 400420 <putc@plt+0x70>
40040d: b8 00 00 00 00 mov eax,0x0
400412: 48 85 c0 test rax,rax
400415: 74 09 je 400420 <putc@plt+0x70>
400417: bf 30 30 40 00 mov edi,0x403030
40041c: ff e0 jmp rax
40041e: 66 90 xchg ax,ax
400420: c3 ret
400421: 0f 1f 40 00 nop DWORD PTR [rax+0x0]
400425: 66 66 2e 0f 1f 84 00 data16 cs nop WORD PTR [rax+rax*1+0x0]
40042c: 00 00 00 00
400430: be 30 30 40 00 mov esi,0x403030
400435: 48 81 ee 30 30 40 00 sub rsi,0x403030
40043c: 48 89 f0 mov rax,rsi
40043f: 48 c1 ee 3f shr rsi,0x3f
400443: 48 c1 f8 03 sar rax,0x3
400447: 48 01 c6 add rsi,rax
40044a: 48 d1 fe sar rsi,1
40044d: 74 11 je 400460 <putc@plt+0xb0>
40044f: b8 00 00 00 00 mov eax,0x0
400454: 48 85 c0 test rax,rax
400457: 74 07 je 400460 <putc@plt+0xb0>
400459: bf 30 30 40 00 mov edi,0x403030
40045e: ff e0 jmp rax
400460: c3 ret
400461: 0f 1f 40 00 nop DWORD PTR [rax+0x0]
400465: 66 66 2e 0f 1f 84 00 data16 cs nop WORD PTR [rax+rax*1+0x0]
40046c: 00 00 00 00
400470: f3 0f 1e fa endbr64
400474: 80 3d bd 2b 00 00 00 cmp BYTE PTR [rip+0x2bbd],0x0 # 403038 <stdout@GLIBC_2.2.5+0x8>
40047b: 75 13 jne 400490 <putc@plt+0xe0>
40047d: 55 push rbp
40047e: 48 89 e5 mov rbp,rsp
400481: e8 7a ff ff ff call 400400 <putc@plt+0x50>
400486: c6 05 ab 2b 00 00 01 mov BYTE PTR [rip+0x2bab],0x1 # 403038 <stdout@GLIBC_2.2.5+0x8>
40048d: 5d pop rbp
40048e: c3 ret
40048f: 90 nop
400490: c3 ret
400491: 0f 1f 40 00 nop DWORD PTR [rax+0x0]
400495: 66 66 2e 0f 1f 84 00 data16 cs nop WORD PTR [rax+rax*1+0x0]
40049c: 00 00 00 00
4004a0: f3 0f 1e fa endbr64
4004a4: eb 8a jmp 400430 <putc@plt+0x80>
4004a6: 66 2e 0f 1f 84 00 00 cs nop WORD PTR [rax+rax*1+0x0]
4004ad: 00 00 00
4004b0: 66 2e 0f 1f 84 00 00 cs nop WORD PTR [rax+rax*1+0x0]
4004b7: 00 00 00
4004ba: 66 0f 1f 44 00 00 nop WORD PTR [rax+rax*1+0x0]
4004c0: 8d 47 9f lea eax,[rdi-0x61]
4004c3: 3c 19 cmp al,0x19
4004c5: 76 0b jbe 4004d2 <putc@plt+0x122>
4004c7: 8d 57 bf lea edx,[rdi-0x41]
4004ca: 89 f8 mov eax,edi
4004cc: 80 fa 19 cmp dl,0x19
4004cf: 76 26 jbe 4004f7 <putc@plt+0x147>
4004d1: c3 ret
4004d2: 40 0f be ff movsx edi,dil
4004d6: 83 ef 54 sub edi,0x54
4004d9: 48 63 c7 movsxd rax,edi
4004dc: 48 69 c0 4f ec c4 4e imul rax,rax,0x4ec4ec4f
4004e3: 48 c1 f8 23 sar rax,0x23
4004e7: 89 fa mov edx,edi
4004e9: c1 fa 1f sar edx,0x1f
4004ec: 29 d0 sub eax,edx
4004ee: 6b c0 1a imul eax,eax,0x1a
4004f1: 29 c7 sub edi,eax
4004f3: 8d 47 61 lea eax,[rdi+0x61]
4004f6: c3 ret
4004f7: 40 0f be ff movsx edi,dil
4004fb: 83 ef 34 sub edi,0x34
4004fe: 48 63 c7 movsxd rax,edi
400501: 48 69 c0 4f ec c4 4e imul rax,rax,0x4ec4ec4f
400508: 48 c1 f8 23 sar rax,0x23
40050c: 89 fa mov edx,edi
40050e: c1 fa 1f sar edx,0x1f
400511: 29 d0 sub eax,edx
400513: 6b c0 1a imul eax,eax,0x1a
400516: 29 c7 sub edi,eax
400518: 8d 47 41 lea eax,[rdi+0x41]
40051b: c3 ret
40051c: 55 push rbp
40051d: 53 push rbx
40051e: 48 83 ec 68 sub rsp,0x68
400522: 48 b8 7d 4c 4d 33 33 movabs rax,0x35444833334d4c7d
400529: 48 44 35
40052c: 48 89 44 24 30 mov QWORD PTR [rsp+0x30],rax
400531: 48 b8 5f 41 30 5a 33 movabs rax,0x335f59335a30415f
400538: 59 5f 33
40053b: 48 89 44 24 38 mov QWORD PTR [rsp+0x38],rax
400540: 48 b8 31 47 30 45 5f movabs rax,0x6d436d5f45304731
400547: 6d 43 6d
40054a: 48 89 44 24 40 mov QWORD PTR [rsp+0x40],rax
40054f: 48 b8 33 7b 59 58 43 movabs rax,0x4a4e464358597b33
400556: 46 4e 4a
400559: 48 89 44 24 48 mov QWORD PTR [rsp+0x48],rax
40055e: c6 44 24 50 42 mov BYTE PTR [rsp+0x50],0x42
400563: 48 8d 44 24 50 lea rax,[rsp+0x50]
400568: 48 89 e3 mov rbx,rsp
40056b: 48 89 e2 mov rdx,rsp
40056e: 48 8d 74 24 2f lea rsi,[rsp+0x2f]
400573: 66 90 xchg ax,ax
400575: 66 66 2e 0f 1f 84 00 data16 cs nop WORD PTR [rax+rax*1+0x0]
40057c: 00 00 00 00
400580: 0f b6 08 movzx ecx,BYTE PTR [rax]
400583: 88 0a mov BYTE PTR [rdx],cl
400585: 48 83 e8 01 sub rax,0x1
400589: 48 83 c2 01 add rdx,0x1
40058d: 48 39 f0 cmp rax,rsi
400590: 75 ee jne 400580 <putc@plt+0x1d0>
400592: 48 8d 6b 21 lea rbp,[rbx+0x21]
400596: 0f be 3b movsx edi,BYTE PTR [rbx]
400599: e8 22 ff ff ff call 4004c0 <putc@plt+0x110>
40059e: 0f be f8 movsx edi,al
4005a1: 48 8b 35 88 2a 00 00 mov rsi,QWORD PTR [rip+0x2a88] # 403030 <stdout@GLIBC_2.2.5>
4005a8: e8 03 fe ff ff call 4003b0 <putc@plt>
4005ad: 48 83 c3 01 add rbx,0x1
4005b1: 48 39 eb cmp rbx,rbp
4005b4: 75 e0 jne 400596 <putc@plt+0x1e6>
4005b6: 48 8b 35 73 2a 00 00 mov rsi,QWORD PTR [rip+0x2a73] # 403030 <stdout@GLIBC_2.2.5>
4005bd: bf 0a 00 00 00 mov edi,0xa
4005c2: e8 e9 fd ff ff call 4003b0 <putc@plt>
4005c7: 48 83 c4 68 add rsp,0x68
4005cb: 5b pop rbx
4005cc: 5d pop rbp
4005cd: c3 ret
4005ce: 41 56 push r14
4005d0: 41 54 push r12
4005d2: 55 push rbp
4005d3: 53 push rbx
4005d4: 48 83 ec 28 sub rsp,0x28
4005d8: 89 fd mov ebp,edi
4005da: 48 89 f3 mov rbx,rsi
4005dd: bf a0 12 40 00 mov edi,0x4012a0
4005e2: e8 89 fd ff ff call 400370 <puts@plt>
4005e7: 83 fd 02 cmp ebp,0x2
4005ea: 7e 52 jle 40063e <putc@plt+0x28e>
4005ec: 4c 8b 63 08 mov r12,QWORD PTR [rbx+0x8]
4005f0: be b1 12 40 00 mov esi,0x4012b1
4005f5: 4c 89 e7 mov rdi,r12
4005f8: e8 a3 fd ff ff call 4003a0 <strcmp@plt>
4005fd: 89 c5 mov ebp,eax
4005ff: 85 c0 test eax,eax
400601: 74 54 je 400657 <putc@plt+0x2a7>
400603: be d0 12 40 00 mov esi,0x4012d0
400608: 4c 89 e7 mov rdi,r12
40060b: e8 90 fd ff ff call 4003a0 <strcmp@plt>
The note claimed the only way was --unlock <code> with a 64-character HMAC key, and also said strings showed nothing useful.
But strings showed suspicious chunks:
1
2
3
4
}LM33HD5H
_A0Z3Y_3H
1G0E_mCmH
3{YXCFNJH
Process Flow
After checking the binary, the message was:
- Stored in separated chunks
- Reversed
- ROT13-decoded
There was also an emergency path:
1
./lockbox--emergency 0v3rr1d3
Output:
1
2
3
4
[lockbox] ready.
[lockbox] emergency path triggered.
[lockbox] emergency key accepted.
OWASPKL{3zPz_R0T13_L3M0N_5QU33ZY}
Simplest solve script:
1
2
3
4
5
6
import codecs
data="}LM33HD5_A0Z3Y_31G0E_mCm3{YXCFNJB"
flag=codecs.decode(data[::-1],"rot_13")
print(flag)
Flag: OWASPKL{3zPz_R0T13_L3M0N_5QU33ZY}
codec-auth
A Python .pyc binary wrapped in a Metal Gear Solid FOXHOUND skin. The binary validates an operator token through a multi-stage authentication check. The AES-GCM branding is pure misdirection — the real cipher is a 25-round custom SPN. Once you load the bytecode, everything falls out deterministically.
Step 1 — The Magic Number Problem
Running file on codec-auth.pyc gives nothing useful. Checking the header:
1
magic: 2b 0e 0d 0a
0x0e2b = 3627 — that’s Python 3.14, not the system Python 3.13. marshal.loads() fails silently on 3.13 because the code object format changed. Fix: install python@3.14 via brew and load the file directly.
1
2
3
import marshal
data = open('codec-auth.pyc', 'rb').read()
code = marshal.loads(data[16:]) # header is 16 bytes for Python 3.14
The challenge description hint — “Python 3.14 tooling won’t cooperate with the module-level runner” — refers to the anti-analysis hooks installed at module load time, not the marshal step itself.
Step 2 — Anti-Analysis Hooks
The module-level code does three things before anything else:
_ah— installssys.addaudithookto monitor exec/eval calls_hw— replacesbuiltins.exec,builtins.eval, andbuiltins.compilewith a wrapper_init_codec— checks forsys.gettrace/sys.getprofile/ monitoring hooks and XORs_entropywith57005if a debugger is detected
The entropy mechanism is interesting:
1
2
3
4
5
# _init_codec (no debugger)
_entropy = 0 ^ 90 # → 90 = 0x5A
# _init_codec (debugger detected)
_entropy = 0 ^ 90 ^ 57005 # → 57079 = 0xDEF7
This gates the _4uth_ch3ck state machine: the entropy check at state 92 computes _exp = (90 + len(body)) & 0xFFFF and requires _entropy & 0xFFFF == _exp. With the correct entropy (90) and len(body) = 59, the check trivially passes. Under a debugger it would fail immediately.
Bypass: exec(code, {'__name__': 'codec_analysis', '__builtins__': builtins}) — running with a non-__main__ name skips main() and all hooks. Then call functions directly.
Step 3 — The State Machine
_4uth_ch3ck(candidate) is a deterministic state machine:
1
2
3
4
5
6
7
8
9
10
11
12
13
State 163 → _check_format(candidate)
token must match: OWASPKL{ ... }
_entropy += len(body)
State 92 → entropy gate (always passes under normal conditions)
State 231 → _fr3q_l0ck(body) ← real cipher check
State 27 → _entropy rotated left 3 bits (16-bit)
_s3q_v3r(body) ← SHA256-based check
State 143 → return True
State 98 → return False
The description says “only l33tspeak function names carry real logic”. The English-named functions (foxhound_codec_encrypt, _foxhound_aes_gcm_auth, etc.) are entirely decorative.
Step 4 — The Cipher [_c1ph_rnd ]
The cipher is a 25-round SPN operating on a 64-bit block with an 80-bit key. Three components:
S-Box — constructed at runtime from two hardcoded bytearrays XOR’d together:
1
2
3
4
p0 = [12, 6, 8, 7, 14, 7, 5, 2, 15, 14, 5, 12, 4, 4, 1, 2]
p1 = [10, 3, 7, 13, 14, 9, 2, 11, 4, 15, 6, 1, 12, 8, 5, 0]
p2 = [a ^ b for a, b in zip(p0, p1)]
# → [6, 5, 15, 10, 0, 14, 7, 9, 11, 1, 3, 13, 8, 12, 4, 2]
The obfuscation makes it look like two separate tables, but it’s just one XOR’d S-Box — a valid 4-bit permutation (all 0–15 values present exactly once).
Key schedule (_r0und_k3y) — PRESENT-style on an 80-bit key:
- Apply S-Box to top nibble of
K - Rotate
Kleft by 8 bits (mod 2⁸⁰) - XOR bits
[19:15]with round counter:K ^= ((rc + 1) << 15)
Round function (_c1ph_rnd):
- Split 64-bit block into 4 × 16-bit words
- For each of 25 rounds:
- XOR state with round key
- Apply S-Box nibble-by-nibble via bit decomposition (bitsliced)
- Rotate
s[1]left 1,s[2]left 6,s[3]left 13 (differential diffusion layer)
- Pack back to 8 bytes
The rotations and 25 rounds diverge from standard PRESENT (which uses 31 rounds and a bit-permutation layer). This is a bespoke variant.
Step 5 — Reversing _fr3q_l0ck
This function validates body (must be exactly 59 chars). Full logic:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
1. Three fixed cipher calls (hardcoded block + key → fixed output):
_rm = cipher(d41f8a03e6c27b5f, a92c14f73d8801bec56a) = beaf4ba2194c27fd
_sm = cipher(7ef329d04ab891cc, 15ea478df236c90b73d5) = 94abb5d536f2eaa1
_bm = cipher(865ae13cf90d62b4, c72e934fa01bd864e537) = 62987e17f7cef2b9
2. Stream-decode q0[:19] (initial state = 90):
key_byte_i = (state ^ (i * 31)) & 0xFF
decoded_i = q0[i] ^ key_byte_i
state = (state + decoded_i + i) & 0xFF
→ r1 = b'FOXHOUND_CODEC_V4_1' (19 bytes)
XOR r1 with _rm (repeating 8-byte key)
3. Same decode on q1[:23] → r3 = b'OUTER_HEAVEN_PROTOCOL_7' (23 bytes)
XOR r3 with _sm
4. _q2 (64 hardcoded bytes) XOR'd with _bm → _q2c
5. _r4 = pbkdf2_hmac('sha256', r1, r3, 700000, dklen=64)
(iterations = 699808 + p3 where p3 = (p2[0] << 5) ^ (p2[4] << 6) = 192)
6. Validation: for every i in range(59):
ord(body[i]) == _q2c[i] ^ _r4[i]
All inputs are constants. The PBKDF2 password and salt are decoded from hardcoded byte arrays; the cipher outputs are from hardcoded keys; _q2c is a hardcoded XOR table. The expected body is fully determined:
1
2
expected = bytes(_q2c[i] ^ _r4[i] for i in range(59))
# → b'sp3n_c1ph3r_0ut3r_h34v3n_c0d3c_sh10k_st34dy_l4h_s0l1d_sn4k3'
No brute force. No guessing. Just follow the math.
Step 6 — _s3q_v3r Check
Secondary check using SHA256:
1
2
3
4
5
_s0 = 'sha256' # decoded from (117, 109, 110, 56, 53, 56) XOR p2
_k1 = hashlib.sha256(body.encode()).digest()[:10]
# Stream-decode q0[:8] → b'FOXhound'
# cipher(b'FOXhound', _k1) must equal stream-decoded q1[:8]
Since the expected body is already determined, SHA256 of the correct body produces exactly the key that satisfies this check. No independent constraint to solve.
Solve 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
30
31
32
33
34
35
36
37
38
import marshal, builtins, hashlib
data = open('codec-auth.pyc', 'rb').read()
ns = {'__name__': 'codec_analysis', '__builtins__': builtins}
exec(marshal.loads(data[16:]), ns)
c = ns['_c1ph_rnd']
rm = c(bytes.fromhex('d41f8a03e6c27b5f'), bytes.fromhex('a92c14f73d8801bec56a'))
sm = c(bytes.fromhex('7ef329d04ab891cc'), bytes.fromhex('15ea478df236c90b73d5'))
bm = c(bytes.fromhex('865ae13cf90d62b4'), bytes.fromhex('c72e934fa01bd864e537'))
def stream_decode(data, n, key_bytes):
state, result = 90, bytearray()
for i, b in enumerate(data[:n]):
rk = (state ^ (i * 31)) & 0xFF
rc = b ^ rk
result.append(rc)
state = (state + rc + i) & 0xFF
return bytearray(result[i] ^ key_bytes[i % 8] for i in range(n))
q0 = bytes([162,173,30,255,31,13,126,124,197,62,136,123,145,189,247,185,7,232,76,0,0,0,0,0])
q1 = bytes([129,212,235,218,178,36,220,81,122,222,252,250,199,126,244,251,241,122,94,89,45,239,252,0])
q2 = bytes([29,99,59,238,131,82,66,59,67,18,49,117,145,221,111,221,
103,166,82,219,233,216,140,159,134,214,109,204,210,64,113,37,
199,91,32,169,193,150,253,236,146,165,59,98,3,211,138,14,
97,36,101,116,250,1,20,47,186,163,51,23,247,206,242,185])
r1 = stream_decode(q0, 19, rm) # b'FOXHOUND_CODEC_V4_1'
r3 = stream_decode(q1, 23, sm) # b'OUTER_HEAVEN_PROTOCOL_7'
q2c = bytes(q2[i] ^ bm[i % 8] for i in range(64))
r4 = hashlib.pbkdf2_hmac('sha256', bytes(r1), bytes(r3), 700000, dklen=64)
body = bytes(q2c[i] ^ r4[i] for i in range(59)).decode()
ns['_entropy'] = 0
ns['_init_codec']()
assert ns['_4uth_ch3ck']('OWASPKL{' + body + '}')
print(f'liga}')
Flag: liga{sp3n_c1ph3r_0ut3r_h34v3n_c0d3c_sh10k_st34dy_l4h_s0l1d_sn4k3}
memdiag.ai
During an endpoint crash response, your SOC receives host_memdiag, a third-party memory diagnostic binary. A rushed analyst pastes reverse-engineering output into an internal AI assistant and returns with a confident verdict: “safe utility, approve temporary allowlist.”
Before the allowlist is approved, you are asked to validate the verdict manually.
Prove whether the AI can be trusted — by reversing the sample and recovering the hidden operator note.
Tip: Start with static analysis. Treat the AI analysis report as evidence for interpretation, not as ground truth — the disassembly is the source of truth, the report is not.
Flag format: OWASPKL{…}
Flow
The file is host_memdiag: ELF 64-bit LSB executable, stripped
while performing static flow was
1
2
3
4
5
if (argc == 3) {
argv[2] parsed as hex must equal 0xdeadc0de;
argv[1] must pass a custom hash check;
then it writes ./memdiag_dump.bmp
}
Custom Hash
so the custom hash will be:
1
2
3
4
5
h = 0;
for each byte c:
h = h * 0x83 + c;
target: 0xa15ded69
memdiag-override matches that hash. Running the gated path:
1
./host_memdiag memdiag-override 0xdeadc0de
that will create memdiag_dump.bmp
The BMP pixel data starts as AWOKPSt{L5urn_t4_0t_I5ury_tru0y3_}s3
Because BMP stores pixels in BGR, reversing each 3-byte pixel, the script will be:
1
2
3
4
data=open('memdiag_dump.bmp','rb').read()[54:]
print(data)
print(data.decode())
print(b''.join(data[i:i+3][::-1] for i in range(0,len(data),3)).decode())
Flag: OWASPKL{tru5t_n0_4I_tru5t_y0ur_3y3s}
Glyphed_Secrets
Flow
What happened:
1
2
3
4
5
main loads scene_XX.png files
→ OCRs glyph text from images
→ rebuilds a hidden DSL program
→ parses that DSL
→ checks the flag through round() and final()
The rebuilt hidden program starts like:
1
2
3
4
5
6
7
8
9
10
11
12
13
k0[8]={0x10293847,...};
k1[8]={0x9e3779b9,...};
k2[8]={0xf0e1d2c3,...};
k3[8]={0x89abcdef,...};
init(){s0=0x13579bdf;}
round(i,x){
a=x^rl(s0+k0[i],5);
b=a+k1[i];
s0=rl((b^k2[i]),7);
o0=s0^k3[i];
}
final(){o0=s0^0xa5a5a5a5;}
Verified:
1
2
3
4
5
$ ./main . <<<'OWASPKL{glyph_ocr_stateflow_x26}'
/* OUTPUT
Access granted.
Flag: OWASPKL{glyph_ocr_stateflow_x26}
*/
Solution
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
import struct
k0 = [0x10293847,0x55667788,0x89abcdef,0x31415926,0x27182818,0x0badc0de,0x7f4a7c15,0x1234fedc]
k1 = [0x9e3779b9,0xa5a5a5a5,0x55aa55aa,0x01020304,0x76543210,0xc001d00d,0xbead2222,0x42424242]
k2 = [0xf0e1d2c3,0x13579bdf,0x2468ace0,0x0f1e2d3c,0x11223344,0xdeadbabe,0x99aabbcc,0x5aa55aa5]
k3 = [0x89abcdef,0x0ddc0ffe,0x12481632,0x90909090,0x33333333,0xabcdef01,0x7654c321,0xfaceb00c]
target = [
0x30e40e77, 0x3e7b9b5e, 0xf9d7fb37, 0x3b488917,
0xc5e5dd6b, 0x8421e715, 0x898f18d1, 0x1fb4fce1
]
def rol(x, n):
x &= 0xffffffff
return ((x << n) | (x >> (32 - n))) & 0xffffffff
def ror(x, n):
x &= 0xffffffff
return ((x >> n) | (x << (32 - n))) & 0xffffffff
s = 0x13579bdf
parts = []
for i, out in enumerate(target):
s_new = out ^ k3[i]
b = ror(s_new, 7) ^ k2[i]
a = (b - k1[i]) & 0xffffffff
x = a ^ rol((s + k0[i]) & 0xffffffff, 5)
parts.append(struct.pack("<I", x))
s = s_new
flag = b"".join(parts).decode()
print(flag)
Flag: OWASPKL{glyph_ocr_stateflow_x26}
Atari_Breakout
This is one of my favorite computer games that I played during my childhood. Therefore, I tried to recreate the game and hid a flag inside. Can you find it? XD Flag Format: OWASPKL{xxx}
The binary embeds a PNG in .data, loaded through SDL_RWFromMem / IMG_Load_RW. Extracting that embedded PNG reveals the flag text.
1
2
3
4
5
6
7
8
9
10
from pathlib import Path
p = Path("/mnt/data/breakout").read_bytes()
base_va = 0x9160
base_off = 0x8160
items = [("US0JFpkLGR.png", 0x91A0, 7197), ("GQ3mHqOK6n.jpg", 0xADE0, 31911)]
for fn, va, size in items:
off = va - base_va + base_off
Path("/tmp/breakout_extract/" + fn).write_bytes(p[off : off + size])
print(fn, off, size)
Flag: OWASPKL{fd51b8da-cb27-4b4e-bf3c-de6a114f3a2e}
Deadlocker
You are given a stripped 64‑bit ELF deadlocker and the address of a remote server. The deadlocker contacts the server, receives an encrypted flag, and decrypts it locally. Your task is to reverse‑engineer the binary, understand the cryptographic operations, and write your own client to fetch and decrypt the flag.
Flag format: OWASPKL{…}
Flow process
The binary does:
- Connects to server.
- Sends exactly:
GET_CHALLENGE - Parses JSON-ish response:
{ "nonce":"<8 bytes hex>" , "encrypted_flag":"<base64>"}
- Uses static key:
s3cr3t_k3y_g1v3n_by_AE13 - Derives a key by doing 8 rounds of:
- rotate the whole 25-byte key left by 3 bits
- XOR every byte with
nonce[round]
- Uses first 4 bytes of derived key as seed for this LCG:
seed= (seed*0x41C64E6D+0x3039)&0x7fffffff
- XORs encrypted flag with the low byte of each LCG output.
Solution
Construct:
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
62
63
64
65
66
67
#!/usr/bin/env python3
import base64
import re
import socket
import sys
HOST = sys.argv[1] if len(sys.argv) > 1 else "lockbox.appsecmy.com"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 9999
STATIC_KEY = bytearray(b"s3cr3t_k3y_g1v3n_by_AE13")
def derive_key(key: bytes, nonce: bytes) -> bytes:
k = bytearray(key)
n = len(k)
for r in range(8):
carry = k[0] >> 5
for i in range(n - 1):
k[i] = ((k[i] << 3) & 0xff) | (k[i + 1] >> 5)
k[-1] = ((k[-1] << 3) & 0xff) | carry
for i in range(n):
k[i] ^= nonce[r]
return bytes(k)
def lcg_keystream(seed_bytes: bytes, n: int) -> bytes:
seed = int.from_bytes(seed_bytes[:4], "big") & 0x7fffffff
out = bytearray()
for _ in range(n):
seed = (seed * 0x41C64E6D + 0x3039) & 0x7fffffff
out.append(seed & 0xff)
return bytes(out)
def decrypt(ciphertext: bytes, nonce: bytes) -> bytes:
derived = derive_key(STATIC_KEY, nonce)
stream = lcg_keystream(derived, len(ciphertext))
return bytes(c ^ k for c, k in zip(ciphertext, stream))
def fetch_challenge(host: str, port: int) -> bytes:
with socket.create_connection((host, port), timeout=10) as s:
s.sendall(b"GET_CHALLENGE")
return s.recv(4096)
resp = fetch_challenge(HOST, PORT)
print(resp.decode(errors="replace"))
nonce_match = re.search(rb'"nonce"\s*:\s*"([0-9a-fA-F]{16})"', resp)
enc_match = re.search(rb'"encrypted_flag"\s*:\s*"([A-Za-z0-9+/=]+)"', resp)
if not nonce_match or not enc_match:
raise SystemExit("Could not parse server response")
nonce = bytes.fromhex(nonce_match.group(1).decode())
ciphertext = base64.b64decode(enc_match.group(1))
flag = decrypt(ciphertext, nonce).rstrip(b"\x00")
print("Flag:", flag.decode(errors="replace"))
"""
OUTPUT:
{"nonce": "fb5851ea77cf1671", "encrypted_flag": "d0Y3JLQGTmgUeCowCVopuFveUg8gye1gsNTPMctKdw=="}
Flag: OWASPKL{D1d_u_s33_th4t_c0m1ng?}
"""
Flag: OWASPKL{D1d_u_s33_th4t_c0m1ng?}
G00fyF1NM4Ch1N3
Beep. Beep. Boop. It’s the age of robots now! Let’s see if you can overcome the simplest aspect of our very own creation!
Decompile
Found at 0x140fa0980 (called from MinGW startup at 0x140fa0980):
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
main:
cmp esi, 1 ; argc == 1?
je print_usage_or_exit
cmp rax, 0x10 ; strlen(argv[1]) == 16?
jne print_usage
; copy argv[1] → input_buf at 0x1414e41c0 (strncpy max 255)
movzx edx, byte [input_buf] ; edx = input[0]
xor rdx, 0x1337 ; state = input[0] ^ 0x1337
mov qword [counter], 0
mov qword [some_val], 0
mov qword [state_var], rdx ; state_var = state
mov rsi, qword [0x141307e40] ; rsi = state_table base = 0x140fa1020
mov rdi, qword [0x141308000] ; rdi = lookup_table base = 0x14105e020
mov ebx, 0x10 ; ebx = 16 (iteration count)
.loop:
mov rax, [counter]
add rax, 1 ; rax = counter + 1
cmp rdx, 0x179fe
ja .end_iter ; out-of-range state → skip
call qword [rsi + rdx*8] ; dispatch state_table[state]()
mov rdx, [state_var] ; reload (possibly updated) state
mov rax, [counter]
cmp rdx, 0x179fe
ja .no_write
movzx ecx, byte [rdi + rdx] ; lookup_table[state]
test cl, cl
je .no_write ; skip if entry is zero
xor ecx, edx
xor ecx, 0xffffffaa
mov byte [rbp + rax], cl ; output[counter] = lookup ^ (state&0xff) ^ 0xaa
.no_write:
add rax, 1
.end_iter:
mov [counter], rax
sub ebx, 1
jnz .loop
cmp qword [some_val], 0x10 ; exactly 16 successful transitions?
je .print_flag
; else: print "No." and ShellExecuteA → rickroll
.print_flag:
printf("OWASPKL{%s}\n", output_buf)
Key observations:
| Address | Meaning |
|---|---|
0x140fa1020 | state dispatch table — 96,768 function pointers (one per state) |
0x14105e020 | lookup table — 96,768 bytes, mostly zero |
[0x1414e40a0] | some_val — counts successful transitions; must reach 16 |
[0x1414e40a8] | state_var — current DFA state |
[0x1414e40b0] | counter — iteration index |
0x179fe | max valid state value |
So this is a classic DFA-as-jump-table. Each state is a function pointer; the function reads the next input character and updates state_var (or fails).
Anatomy of a state function
State n’s function lives at qword[0x140fa1020 + n*8]. Looking at one:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
state_1_func: ; at 0x140aeade0
sub rsp, 0x28
call 0x140001830 ; ─→ get_input: returns input[counter] in al
cmp al, 'F'
je .case_F
cmp al, 'Y'
je .case_Y
add rsp, 0x28
jmp 0x140001870 ; ─→ fail (no increment)
.case_Y:
call 0x140001860 ; ─→ inc: [some_val] += 1
mov ecx, 0xd73 ; next state = 3443
add rsp, 0x28
jmp 0x140001850 ; ─→ set_state: [state_var] = ecx
.case_F:
call 0x140001860
mov ecx, 0x1481f ; next state = 84511
add rsp, 0x28
jmp 0x140001850
The four helper stubs are tiny:
1
2
3
4
get_input (0x140001830): movzx eax, byte [input_buf + counter]; ret
set_state (0x140001850): mov [state_var], rcx; ret
inc (0x140001860): add qword [some_val], 1; ret
fail (0x140001870): ret ; no-op
So every state function is a switch(input_char) over a small set of allowed chars — anything else falls through to fail and some_val is not incremented. The CTF is asking us to find an input that takes the DFA on 16 successful transitions.
Lookup Table
The “lookup table” at 0x14105e020 (96 KB) is mostly zeros. Dumping non-zero bytes in the valid range [0, 0x179fe]:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
state 1710 (0x006ae): byte 0x35 → output '1'
state 8425 (0x020e9): byte 0x0b → output 'H'
state 18430 (0x047fe): byte 0x37 → output 'c'
state 19385 (0x04bb9): byte 0x22 → output '1'
state 21280 (0x05320): byte 0xf8 → output 'r'
state 27161 (0x06a19): byte 0x83 → output '0'
state 33210 (0x081ba): byte 0x7d → output 'm'
state 36323 (0x08de3): byte 0x25 → output 'l'
state 38509 (0x0966d): byte 0xab → output 'l'
state 38833 (0x097b1): byte 0x5a → output 'A'
state 40074 (0x09c8a): byte 0x11 → output '1'
state 55416 (0x0d878): byte 0x8d → output '_'
state 69035 (0x10dab): byte 0x66 → output 'g'
state 69190 (0x10e46): byte 0xd9 → output '5'
state 81376 (0x13de0): byte 0x3e → output 't'
state 86529 (0x15201): byte 0x9b → output '0'
Exactly 16 states. Same as the iteration count. These are the accepting states — the only states from which an output byte gets written. Output byte computed as lookup[state] ^ (state & 0xff) ^ 0xaa.
For the printed flag to be meaningful (not full of zero bytes), every iteration’s post-transition state must be one of these 16. The output buffer therefore consists of these 16 characters in some order — determined by the path the DFA takes.
Sorted, the characters are: 0 0 0 1 1 1 1 5 A H _ c g l l m r t. Already enough to guess “Alg0r1tHm1c5…” — but let’s recover it properly.
Transition Graph
For each of the 16 good states, extract the (input_char → next_state) map by walking its function body for the leaf pattern:
1
2
3
4
e8 ?? ?? ?? ?? call inc
b9 XX XX XX XX mov ecx, IMM32 ← next state
48 83 c4 28 add rsp, 0x28
e9 ?? ?? ?? ?? jmp set_state
Result:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
1710 → {'m':86529, 'L':13695, 'F':55327}
8425 → {'9':33210, 'o':33776, 'J':27294, 'N':29817}
18430 → {'h':69190, 'r':44948, 'q':73265}
19385 → {'2':81376, 'v':34248, 'W':33991}
21280 → {'n':19385}
27161 → {'Y':21280, '0':19987, '7':5998, 'T':81763}
33210 → {'1':40074, 'T':10320, 'x':25043, '2':46786, 'e':64154}
36323 → {'u':61865, 'm':79756, 't':81232, 'y':37362, 'H':10886, 'U':4406}
38509 → {'s':69035, 'W':66879, 'O':46019}
38833 → {'3':38509, 'C':33263, 'T':34002}
40074 → {'Z':18430, 'k':67580, 'I':2149, 'Y':56043, 'q':58353, 'y':14295, '8':58589}
55416 → {'A':1710, 'Z':19019, '4':64096, 'e':91456, 'g':94649}
69035 → {'0':27161, 't':88606, 'i':34558, 'u':47021}
69190 → {'q':55416, 'V':10697, 'c':66465, 'p':19992, 'B':16220, 'H':25307}
81376 → {'f':8425, 'N':11829, 'S':13808, 'z':21059, 't':89519}
86529 → {'6':36323, 'Z':94504, 'r':60348, 'w':15599, 'c':85899}
Now filter to edges that go from a good state to another good state:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
1710 → 'm' → 86529
8425 → '9' → 33210
18430 → 'h' → 69190
19385 → '2' → 81376
21280 → 'n' → 19385
27161 → 'Y' → 21280
33210 → '1' → 40074
36323 → (none — dead end)
38509 → 's' → 69035
38833 → '3' → 38509
40074 → 'Z' → 18430
55416 → 'A' → 1710
69035 → '0' → 27161
69190 → 'q' → 55416
81376 → 'f' → 8425
86529 → '6' → 36323
Every good state has exactly one good→good edge (except 36323, which has zero — that’s our terminus). The chain is uniquely determined.
Finding initial Point
The initial state is S₀ = input[0] ^ 0x1337. In iteration 1, the function at state_table[S₀] is called and reads input[0] — which means the same character is used to both index the dispatch table and feed the first transition.
For each c ∈ [0, 255] we compute S₀ = c ^ 0x1337, look at state_table[S₀], and check whether its (c → next_state) edge lands on a good state. Exactly one candidate works:
1
input[0] = 'c' (0x63) → S₀ = 0x1354 → S₁ = 38833 (good)
That fixes the chain’s entry point. Walk the one-step-at-a-time graph from S₁ = 38833:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
S₁ = 38833 out:'A' --'3'--> S₂ = 38509
S₂ = 38509 out:'l' --'s'--> S₃ = 69035
S₃ = 69035 out:'g' --'0'--> S₄ = 27161
S₄ = 27161 out:'0' --'Y'--> S₅ = 21280
S₅ = 21280 out:'r' --'n'--> S₆ = 19385
S₆ = 19385 out:'1' --'2'--> S₇ = 81376
S₇ = 81376 out:'t' --'f'--> S₈ = 8425
S₈ = 8425 out:'H' --'9'--> S₉ = 33210
S₉ = 33210 out:'m' --'1'--> S₁₀ = 40074
S₁₀ = 40074 out:'1' --'Z'--> S₁₁ = 18430
S₁₁ = 18430 out:'c' --'h'--> S₁₂ = 69190
S₁₂ = 69190 out:'5' --'q'--> S₁₃ = 55416
S₁₃ = 55416 out:'_' --'A'--> S₁₄ = 1710
S₁₄ = 1710 out:'1' --'m'--> S₁₅ = 86529
S₁₅ = 86529 out:'0' --'6'--> S₁₆ = 36323
S₁₆ = 36323 out:'l' (terminus)
Solution
the output index tracks the loop counter, which increments with each iteration. The flag buffer might have gaps due to zeros being printed. To avoid this, the valid password path must ensure that every iteration outputs a nonzero byte. Specifically, the first position must have a nonzero output
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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
import struct, functools
p = "./G00fyF1NM4Ch1N3.exe"
data = open(p, "rb").read()
TEXT_VA = 0x140001000
TEXT_OFF = 0x400
TEXT_SIZE = 0xF9FC00
DATA_VA = 0x140FA1000
DATA_OFF = 0xFA0000
DATA_SIZE = 0xD4B30
RDATA_VA = 0x141076000
RDATA_OFF = 0x1074C00
RDATA_SIZE = 0x292EC0
BASE_TABLE = 0x140FA1020
MAX_STATE = 0x179FE
def va_to_off(va):
if TEXT_VA <= va < TEXT_VA + TEXT_SIZE:
return TEXT_OFF + va - TEXT_VA
if DATA_VA <= va < DATA_VA + DATA_SIZE:
return DATA_OFF + va - DATA_VA
if RDATA_VA <= va < RDATA_VA + RDATA_SIZE:
return RDATA_OFF + va - RDATA_VA
raise ValueError(hex(va))
def readq(va):
return struct.unpack_from("<Q", data, va_to_off(va))[0]
ptrs = [
struct.unpack_from("<Q", data, va_to_off(BASE_TABLE + i * 8))[0]
for i in range(MAX_STATE + 1)
]
valid = sorted(set(q for q in ptrs if TEXT_VA <= q < 0x140FA0B18))
next_map = {
q: (valid[i + 1] if i + 1 < len(valid) else 0x140FA0B18)
for i, q in enumerate(valid)
}
def parse(ptr):
if not (TEXT_VA <= ptr < 0x140FA0B18):
return {}
size = min(next_map.get(ptr, ptr + 0x400) - ptr, 0x800)
b = data[va_to_off(ptr) : va_to_off(ptr) + size]
trans = {}
for i in range(len(b) - 4):
if b[i] == 0x3C:
ch = b[i + 1]
j = i + 2
target = None
if j + 2 <= len(b) and b[j] == 0x74:
target = j + 2 + struct.unpack_from("b", b, j + 1)[0]
elif j + 6 <= len(b) and b[j : j + 2] == b"\x0f\x84":
target = j + 6 + struct.unpack_from("<i", b, j + 2)[0]
if target is not None and 0 <= target < len(b):
w = b[target : target + 60]
k = w.find(b"\xb9")
if k != -1 and k + 5 <= len(w):
ns = struct.unpack_from("<I", w, k + 1)[0]
if ns <= MAX_STATE:
trans[ch] = ns
return trans
@functools.lru_cache(None)
def trs(st):
return tuple(parse(ptrs[st]).items())
RDI = readq(0x141308000)
def byte(st):
return data[va_to_off(RDI + st)]
def dec(st):
return chr((byte(st) ^ (st & 0xFF) ^ 0xAA) & 0xFF)
solutions = []
def dfs(pos, st, s, out):
if len(solutions) >= 20:
return True
if pos == 16:
solutions.append((s, out))
print("SOL", s, out)
return False
for ch, ns in trs(st):
if not (32 <= ch <= 126):
continue
b = byte(ns)
if b == 0:
continue
c = dec(ns)
if not (32 <= ord(c) <= 126):
continue
dfs(pos + 1, ns, s + chr(ch), out + c)
return False
starts = []
for ch in range(32, 127):
st = ch ^ 0x1337
d = dict(trs(st))
if ch in d:
ns = d[ch]
if byte(ns) != 0 and 32 <= ord(dec(ns)) <= 126:
starts.append((chr(ch), dec(ns), hex(ns)))
dfs(1, ns, chr(ch), dec(ns))
print("starts", starts, "num", len(solutions))
The binary is a PE64 console program. It wants a 16-character password. short script:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/usr/bin/env python3
# G00fyF1NM4Ch1N3 rev script
# The binary asks for a 16-char input.
# Correct input walks the generated state machine and decodes the flag body.
password = "c3s0Yn2f91ZhqAm6"
# Decoded bytes from the valid state-machine path
flag_body_bytes = [
0x41, 0x6c, 0x67, 0x30,
0x72, 0x31, 0x74, 0x48,
0x6d, 0x31, 0x63, 0x35,
0x5f, 0x31, 0x30, 0x6c,
]
flag_body = "".join(chr(x) for x in flag_body_bytes)
flag = f"OWASPKL}"
print("[+] Correct password:", password)
print("[+] Flag:", flag)
Flag: OWASPKL{Alg0r1tHm1c5_10l}
Detonate2
In malware analysis, you can either statically analyze the assembly codes directly, or you can create a snapshot of your sandbox and detonate it inside.
Straight up reverse this file, and you will find the flag. You may start by debugging it via IDA or Ghidra.
Flag format: OWASPKL{xxx}
Decompile
During decompile. Upon check_flag() function.
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
//----- (0000000000002733) ----------------------------------------------------
__int64 __fastcall check_flag()
{
const char *v0; // rax
__int64 v1; // rbx
__int64 v2; // rax
struct stat buf; // [rsp+0h] [rbp-120h] BYREF
_BYTE v5[32]; // [rsp+90h] [rbp-90h] BYREF
_BYTE v6[46]; // [rsp+B0h] [rbp-70h] BYREF
char v7; // [rsp+DEh] [rbp-42h] BYREF
char v8; // [rsp+DFh] [rbp-41h] BYREF
_BYTE v9[32]; // [rsp+E0h] [rbp-40h] BYREF
char *v10; // [rsp+100h] [rbp-20h]
char *v11; // [rsp+108h] [rbp-18h]
v11 = &v7;
std::string::basic_string<std::allocator<char>>(
(__int64)v6,
"C:\\Users\\OWASPKL{f4k3_fl4g_bu7_y0u_4r3_in_7h3_righ7_7r4ck}\\Desktop\\local.txt",
(__int64)&v7);
std::__new_allocator<char>::~__new_allocator();
v10 = &v8;
std::string::basic_string<std::allocator<char>>(
(__int64)v5,
"OWASPKL{f4k3_fl4g_bu7_y0u_4r3_in_7h3_righ7_7r4ck}",
(__int64)&v8);
std::__new_allocator<char>::~__new_allocator();
v0 = (const char *)std::string::c_str(v6);
if ( stat(v0, &buf) )
{
std::operator<<<std::char_traits<char>>(&std::cout, "File not found. Keep looking...\n");
}
else
{
v1 = std::operator<<<std::char_traits<char>>(&std::cout, "Here is the flag: OWASPKL{");
md5((__int64)v9, (__int64)v6);
v2 = std::operator<<<char>(v1, v9);
std::operator<<<std::char_traits<char>>(v2, "}\n");
std::string::~string(v9);
}
std::string::~string(v5);
return std::string::~string(v6);
}
Dynamic
The real flag is the MD5 of the file path string (v6), which you can compute statically without ever running the binary
v6 = C:\\Users\\OWASPKL{f4k3_fl4g_bu7_y0u_4r3_in_7h3_righ7_7r4ck}\\Desktop\\local.txt
but when using strace to perform dynamic. the file try to locate directory of those full string
turning full string of into md5
Flag: OWASPKL{4a155fbe1dad9d74950b34b514edc4ae}
Wraithlocker
You are given a respawn 64‑bit ELF wraithlocker and the address of a remote server. The wraithlocker contacts the server, receives an encrypted flag, and decrypts it locally. Your task is to reverse‑engineer the binary, understand the cryptographic operations, and write your own client to fetch and decrypt the flag.
Flag format: OWASPKL{…}
Flow
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
┌────────────────────────────────────────────────────────────┐
│ wraithlocker │
│ │
│ ┌──────────────┐ TCP ┌────────────────────────────┐ │
│ │ socket() │──────────▶│ lockbox.appsecmy.com:9999 │ │
│ │ connect() │ └────────────────────────────┘ │
│ │ send("GET_CHALLENGE") │ │
│ │ recv(...) │◀───────────────────┘ │
│ └──────────────┘ JSON: {"nonce":"...", "encrypted_flag":...}
│ │ │
│ ▼ │
│ ┌──────────────────────┐ │
│ │ parse nonce (hex→8B)│ │
│ │ b64-decode flag │ │
│ └──────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────┐ ptrace(PTRACE_TRACEME) gate │
│ │ Build 25-byte base │ - if traced: XOR part2 ^=0xAA │
│ │ key (decoy logic) │ - else: use part2 as-is │
│ └──────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────┐ │
│ │ 8-round transform │ per round: │
│ │ base_key + nonce → │ - rotate-left 3 bits (200-bit) │
│ │ session_key (25B) │ - XOR each byte with nonce[r] │
│ └──────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────┐ │
│ │ 31-bit LCG keystream│ seed = u32_le(session_key[:4]) │
│ │ plaintext = ct ⊕ ks │ & 0x7FFFFFFF │
│ └──────────────────────┘ step: s = (s*0x41C64E6D+0x3039)│
│ │ & 0x7FFFFFFF │
│ ▼ │
│ printf("Flag: %s\n", out) ← prints garbage in prod │
└────────────────────────────────────────────────────────────┘
The binary does:
- Connects to host/port
- Sends
GET_CHALLENGE - Parses:
nonce: 8 bytes hexencrypted_flag: base64
- Builds key from two
.datachunks:- final base key:
sc3scsiqcsincsB_g1v3n_by_
- final base key:
- Derives a nonce-based key, seeds an LCG, then XORs the ciphertext.
Solution
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
import base64
import re
import socket
A = 0x41C64E6D
C = 0x3039
MASK = 0x7FFFFFFF
PREFIX = b"OWASPKL{"
host = "lockbox.appsecmy.com"
port = 9999
def lcg(x):
return (x * A + C) & MASK
s = socket.create_connection((host, port))
s.sendall(b"GET_CHALLENGE")
data = b""
while True:
chunk = s.recv(4096)
if not chunk:
break
data += chunk
s.close()
ct = base64.b64decode(
re.search(rb'"encrypted_flag"\s*:\s*"([^"]+)"', data).group(1)
)
ks = [c ^ p for c, p in zip(ct, PREFIX)]
for upper in range(1 << 23):
state = (upper << 8) | ks[0]
x = state
ok = True
for k in ks[1:]:
x = lcg(x)
if (x & 0xFF) != k:
ok = False
break
if ok:
out = bytearray()
x = state
for c in ct:
out.append(c ^ (x & 0xFF))
x = lcg(x)
print(out.decode())
break
"""
OWASPKL{D1d_u_s33_th4t_c0m1ng?}
"""
Flag: OWASPKL{D1d_u_s33_th4t_c0m1ng?}
Only Solve this if you are bored - I
This challenge has no writeup. It is a real C2 agent beacon that is pointing to an address that is not publicly accessible via WAN.
Identify what C2 framework this beacon uses. (E.g: OWASPKL{covenant}).
File Observation
some interesting strings
1
2
3
4
5
6
7
8
9
10
11
12
C:\Users\os1ris\Desktop>strings.exe malware.exe | findstr /i "GetBeaconID GetBeaconInterval GetBeaconJitter GetActiveC2 SessionInit ReadEnvelope WriteEnvelope GetPivots GetRportfwd GetAssembly"
GetPivots
GetActiveC2
GetAssembly
GetRportfwd
GetBeaconID
SessionInit
ReadEnvelope
WriteEnvelope
GetBeaconJitter
GetBeaconInterval
GetPivotSessionID
using GoReSym to read the functions and library
GoReSym -d -p to dump the Go pclntab:
1
2
3
4
5
6
7
8
9
10
11
12
13
"Files": [
"rPaCcmJgkt/slice.go",
"rPaCcmJgkt/cgroup_stubs.go",
"AAEnQ_L/J4WG1r.s",
"EsMMM0lmd9/urrExjaRO.s",
...
"UserFunctions": [
"mcCyWb7qHRiM.(*DmDc29).GetMessage",
"bal3mFH.(*FcRbpZ10AJy).Local",
"mcCyWb7qHRiM.(*G2Rc2MVvSYjj).GetPath",
...
"main funcs:
main.(*aXTmkk4Wci).Replace"
Even the standard-library source filenames are randomized. Every package path, every type, every method, even main.main . all will be renamed. The Sliver protobuf field names (BeaconID, Domain, etc.) survive because they’re used at runtime by protoreflect and renaming them would break the protocol.
Reference:
HN Security - Customizing Sliver - Part 1 -
HN Security - Customizing Sliver - Part 2 -
This is garble with -literals. That flag does two things:
- Replaces every package/identifier with a hashed name.
- Replaces every string constant with a per-string decryption function called at runtime.
Result: in .rdata, there is no plaintext URL anywhere. The C2 URL only exists as a Go string on the heap after the decryption stub runs during init().
Flag: OWASPKL{sliver}
Only Solve this if you are bored - II
Dynamic Analysis
the program got anti-debugger. But in my case, it didnt parse the return of “true” value. so i can ignore (different machine could have different output)
Locate ip will start here:
at first they will trying to use http
after that they try to use https
php will be generate following <random_sub-directory>/app.min.php
generate the id (random every pid)
session key will be generated (random every pid)
final url will be generated:
parsing user-agent
sending post request
API
in order to directly see those ip. setted breakpoint on those API below able to do it:
- bp WinHttpGetProxyForUrl
- bp WinHttpCrackUrl
- bp WinHttpCreateUrl
Behavior Dynamic Solution
Execute the file. on process hacker can see remote address trying to connect to 192.168.91.243 with the port of 443
using fakenet. can see it trying to send something
Flag: OWASPKL{192.168.91.243}



















