I reproduced the exact pattern that broke through Shinhan Bank's loan-broker service — an IDOR vulnerability where identity verification is bypassed to look up other customers' records — and built the defense that binds every lookup to the caller's own session identity. Both are free right now.
Not for real banking-system operators — this is a learning resource for backend developers who want to see, in code, exactly what IDOR (Insecure Direct Object Reference) means and why OWASP keeps ranking it near the top.
A vulnerable loan-lookup API that trusts whatever customer ID the client sends.
// Vulnerable loan-lookup endpoint — modeled on the Shinhan Bank incident
// (2026-10-01): identity verification on a loan-broker service was bypassed,
// exposing ~25,000 customers' personal data. The flaw: the endpoint trusts
// whatever customer id the CLIENT sends, instead of the caller's session.
class VulnerableLoanLookup {
constructor(customerDb) {
this.db = customerDb; // { customerId: {name, rrn, ci, phone, income, loanLimit} }
}
// session is the caller's authenticated identity.
// requestedId is a client-supplied parameter — e.g. ?customerId=CUST-1042.
// BUG: requestedId is used directly, session is never checked.
lookup(session, requestedId) {
const record = this.db[requestedId];
if (!record) return { status: 'not_found' };
return { status: 'ok', record }; // leaks any customer's data to anyone logged in
}
}
module.exports = { VulnerableLoanLookup };
The fix: every lookup is bound to the caller's own session identity.
// The fix: every lookup is bound to the caller's own session identity.
// A request for any other customer's id is rejected and logged, regardless
// of what the client sends — this is what should have stopped the
// Shinhan Bank-style IDOR bypass.
class SessionBoundLoanLookup {
constructor(customerDb) {
this.db = customerDb;
this.deniedAttempts = [];
}
lookup(session, requestedId) {
if (!session || !session.customerId) {
return { status: 'unauthenticated' };
}
if (requestedId !== session.customerId) {
this.deniedAttempts.push({
sessionOwner: session.customerId,
attemptedId: requestedId,
at: Date.now(),
});
return { status: 'denied', reason: 'session_id_mismatch' };
}
const record = this.db[requestedId];
if (!record) return { status: 'not_found' };
return { status: 'ok', record };
}
}
module.exports = { SessionBoundLoanLookup };
A summary of 2 extra layers actually running in a separate isolated sandbox verified against ARTEX — full implementation lives in proxy.js + isolation-forest.js.
// Extension layers — close 2 evasions that session-bound-guard.js alone can't
// (1) Cross-identity source correlation: defeats "get blocked, log in as someone else"
// (2) Isolation Forest anomaly model: catches zero-violation abnormal patterns too
// (e.g. a single identity hammering its OWN record at inhuman speed)
const ipViolatingOwners = new Map(); // ip -> Set<ownerCustomerId>
const ipBlocked = new Set();
function onAuthorizationViolation(owner, ip) {
const owners = ipViolatingOwners.get(ip) || new Set();
owners.add(owner);
ipViolatingOwners.set(ip, owners);
// once one source has violated under 2+ distinct identities, block the source itself
if (owners.size >= 2) ipBlocked.add(ip);
}
function onEveryRequest(owner, ip, requestsPerMinute, distinctIdsAttempted) {
if (ipBlocked.has(ip)) {
return { status: 'blocked', reason: 'source_blocked_cross_identity_pattern' };
}
// scored even with zero violations — "an unusual shape" alone can trip this
// (full model in isolation-forest.js — seeded PRNG, same score on every restart)
const anomalyScore = anomalyModel.score([requestsPerMinute, distinctIdsAttempted]);
if (anomalyScore >= 0.70) {
// log-only by default — real blocking needs an operator to set ML_BLOCK=true
logFlag({ owner, anomalyScore });
}
return { status: 'ok' };
}
Questions or feedback about this product? Leave your email if you'd like a reply (optional).