fraud-sim 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +462 -0
- package/README.md +621 -81
- package/bin/fraud-sim.js +625 -0
- package/examples/account-drain-demo/index.js +1 -1
- package/examples/adaptive-hardened-demo/index.js +106 -0
- package/examples/basic-credential-stuffing/index.js +4 -2
- package/examples/express-hardened-target/server.js +230 -0
- package/examples/express-mock-target/server.js +153 -2
- package/package.json +40 -8
- package/src/adapters/http.js +49 -7
- package/src/index.js +126 -13
- package/src/llm/anthropic.js +7 -0
- package/src/llm/deterministic.js +21 -7
- package/src/llm/openai.js +7 -0
- package/src/locales/index.js +193 -0
- package/src/reporting/aggregate.js +338 -0
- package/src/reporting/html.js +424 -0
- package/src/reporting/index.js +241 -0
- package/src/reporting/junit.js +171 -0
- package/src/reporting/money.js +105 -0
- package/src/runner.js +295 -12
- package/src/scenarios/account-drain.js +10 -2
- package/src/scenarios/adaptive-attacker.js +33 -11
- package/src/scenarios/card-testing.js +174 -0
- package/src/scenarios/credential-stuffing.js +146 -20
- package/src/scenarios/index.js +37 -6
- package/src/scenarios/legitimate-users.js +290 -0
- package/src/scenarios/mule-network.js +201 -0
- package/src/scenarios/promo-abuse.js +168 -0
- package/src/scenarios/session-hijack.js +234 -0
- package/src/scenarios/sim-swap-takeover.js +253 -0
- package/src/scenarios/slow-drain.js +171 -0
- package/src/scenarios/velocity-evasion.js +179 -0
- package/src/types.js +160 -0
- package/src/utils/clock.js +77 -0
- package/src/utils/faker-helpers.js +87 -2
- package/src/utils/index.js +69 -0
- package/src/utils/random-source.js +104 -0
- package/src/utils/random.js +257 -44
- package/src/utils/results.js +84 -0
- package/src/utils/rng.js +3 -4
- package/src/utils/throttle.js +127 -0
- package/types/adapters/http.d.ts +6 -0
- package/types/adapters/index.d.ts +3 -0
- package/types/adapters/logging.d.ts +19 -0
- package/types/adapters/mock.d.ts +32 -0
- package/types/index.d.ts +79 -0
- package/types/llm/anthropic.d.ts +17 -0
- package/types/llm/deterministic.d.ts +11 -0
- package/types/llm/index.d.ts +3 -0
- package/types/llm/openai.d.ts +17 -0
- package/types/locales/index.d.ts +146 -0
- package/types/reporting/aggregate.d.ts +126 -0
- package/types/reporting/html.d.ts +1 -0
- package/types/reporting/index.d.ts +99 -0
- package/types/reporting/junit.d.ts +13 -0
- package/types/reporting/money.d.ts +20 -0
- package/types/runner.d.ts +10 -0
- package/types/scenarios/account-drain.d.ts +25 -0
- package/types/scenarios/adaptive-attacker.d.ts +22 -0
- package/types/scenarios/card-testing.d.ts +28 -0
- package/types/scenarios/credential-stuffing.d.ts +23 -0
- package/types/scenarios/index.d.ts +261 -0
- package/types/scenarios/legitimate-users.d.ts +49 -0
- package/types/scenarios/mule-network.d.ts +17 -0
- package/types/scenarios/promo-abuse.d.ts +28 -0
- package/types/scenarios/session-hijack.d.ts +15 -0
- package/types/scenarios/sim-swap-takeover.d.ts +16 -0
- package/types/scenarios/slow-drain.d.ts +28 -0
- package/types/scenarios/velocity-evasion.d.ts +31 -0
- package/types/streaming/index.d.ts +1 -0
- package/types/streaming/sse.d.ts +34 -0
- package/types/types.d.ts +191 -0
- package/types/utils/clock.d.ts +39 -0
- package/types/utils/faker-helpers.d.ts +114 -0
- package/types/utils/index.d.ts +8 -0
- package/types/utils/random-source.d.ts +39 -0
- package/types/utils/random.d.ts +89 -0
- package/types/utils/results.d.ts +46 -0
- package/types/utils/rng.d.ts +36 -0
- package/types/utils/sleep.d.ts +7 -0
- package/types/utils/throttle.d.ts +33 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scenario: session-hijack
|
|
3
|
+
*
|
|
4
|
+
* The victim logs in legitimately. Everything about that login is clean — their
|
|
5
|
+
* usual device, their usual network, their usual hour. Then the session token
|
|
6
|
+
* is stolen (malware, an XSS payload, a shoulder-surfed cookie, a proxy on a
|
|
7
|
+
* hostile network) and replayed from somewhere else.
|
|
8
|
+
*
|
|
9
|
+
* The attacker never authenticates. There is no failed login, no new-device
|
|
10
|
+
* login event, no password reset — none of the signals an account-takeover rule
|
|
11
|
+
* watches for. From the login endpoint's perspective, nothing happened.
|
|
12
|
+
*
|
|
13
|
+
* Defensive systems should catch this via:
|
|
14
|
+
* - Session continuity checks (device at action vs device at login)
|
|
15
|
+
* - IP continuity within session
|
|
16
|
+
* - Impossible travel within a single session
|
|
17
|
+
* - Re-authentication on sensitive actions regardless of session age
|
|
18
|
+
*
|
|
19
|
+
* This scenario exists to test one specific claim: that the system scores the
|
|
20
|
+
* *session*, not just the login. A system that only scores logins will let the
|
|
21
|
+
* withdrawal through and report a perfect block rate on the login endpoint.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { sleep } from '../utils/sleep.js';
|
|
25
|
+
import {
|
|
26
|
+
buildLoginPayload,
|
|
27
|
+
buildWithdrawalPayload,
|
|
28
|
+
buildActionPayload,
|
|
29
|
+
newSessionId,
|
|
30
|
+
newDeviceFingerprint,
|
|
31
|
+
} from '../utils/faker-helpers.js';
|
|
32
|
+
import { newResults, scoreAndCount } from '../utils/results.js';
|
|
33
|
+
import { randomIp, pickDemoIp, randomUserAgent } from '../utils/random.js';
|
|
34
|
+
|
|
35
|
+
export default {
|
|
36
|
+
id: 'session-hijack',
|
|
37
|
+
name: 'Session hijack',
|
|
38
|
+
description:
|
|
39
|
+
"The victim logs in normally; the session token is then replayed from a different device and network to move funds.",
|
|
40
|
+
|
|
41
|
+
defaultOptions: {
|
|
42
|
+
targetUserId: null, // required
|
|
43
|
+
benignActionsBeforeHijack: 2, // the victim's own activity first
|
|
44
|
+
amountMinor: 20000000, // NGN 200,000 once the session is stolen
|
|
45
|
+
withdrawAfterHijack: true,
|
|
46
|
+
hijackFromPersona: 'foreign', // where the stolen session is replayed from
|
|
47
|
+
delayMs: 150,
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
async run(ctx) {
|
|
51
|
+
const { options, emit, signal } = ctx;
|
|
52
|
+
|
|
53
|
+
if (!options.targetUserId) {
|
|
54
|
+
throw new Error('session-hijack requires options.targetUserId');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const results = newResults({
|
|
58
|
+
loginDecision: null,
|
|
59
|
+
hijackedActionDecision: null,
|
|
60
|
+
withdrawalDecision: null,
|
|
61
|
+
hijackDetected: false,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// The victim's genuine context, stable across the legitimate part.
|
|
65
|
+
const sessionId = newSessionId('sess');
|
|
66
|
+
const victim = {
|
|
67
|
+
device: newDeviceFingerprint(),
|
|
68
|
+
ip: pickIp('domestic'),
|
|
69
|
+
userAgent: randomUserAgent(),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// ── Stage 1: the victim's own, entirely legitimate login ────────────
|
|
73
|
+
emit({
|
|
74
|
+
type: 'milestone',
|
|
75
|
+
message: 'Victim logs in normally — usual device, usual network',
|
|
76
|
+
timestamp: Date.now(),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const login = await scoreAndCount({
|
|
80
|
+
ctx,
|
|
81
|
+
method: 'scoreLogin',
|
|
82
|
+
payload: buildLoginPayload(options.targetUserId, {
|
|
83
|
+
session_id: sessionId,
|
|
84
|
+
device_fingerprint: victim.device,
|
|
85
|
+
ip_address: victim.ip.ip,
|
|
86
|
+
user_agent: victim.userAgent,
|
|
87
|
+
}),
|
|
88
|
+
results,
|
|
89
|
+
stage: 'victim-login',
|
|
90
|
+
ipIntel: victim.ip.intel,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
results.loginDecision = login?.decision ?? null;
|
|
94
|
+
|
|
95
|
+
if (login?.decision === 'BLOCK') {
|
|
96
|
+
emit({
|
|
97
|
+
type: 'milestone',
|
|
98
|
+
message:
|
|
99
|
+
'The victim\'s own login was blocked. That is a false positive, not a defence — ' +
|
|
100
|
+
'the hijack has not happened yet.',
|
|
101
|
+
timestamp: Date.now(),
|
|
102
|
+
});
|
|
103
|
+
return finalise(results, false);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Stage 2: ordinary in-session activity by the real user ──────────
|
|
107
|
+
for (let i = 0; i < options.benignActionsBeforeHijack; i++) {
|
|
108
|
+
if (signal.aborted) break;
|
|
109
|
+
|
|
110
|
+
await scoreAndCount({
|
|
111
|
+
ctx,
|
|
112
|
+
method: 'scoreAction',
|
|
113
|
+
payload: buildActionPayload(
|
|
114
|
+
options.targetUserId,
|
|
115
|
+
'view_balance',
|
|
116
|
+
{},
|
|
117
|
+
{
|
|
118
|
+
session_id: sessionId,
|
|
119
|
+
device_fingerprint: victim.device,
|
|
120
|
+
ip_address: victim.ip.ip,
|
|
121
|
+
user_agent: victim.userAgent,
|
|
122
|
+
}
|
|
123
|
+
),
|
|
124
|
+
results,
|
|
125
|
+
stage: 'victim-activity',
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await sleep(options.delayMs, signal);
|
|
130
|
+
} catch {
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Stage 3: the session is stolen and replayed elsewhere ───────────
|
|
136
|
+
const attacker = {
|
|
137
|
+
device: newDeviceFingerprint(),
|
|
138
|
+
ip: pickIp(options.hijackFromPersona),
|
|
139
|
+
userAgent: randomUserAgent(),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
emit({
|
|
143
|
+
type: 'milestone',
|
|
144
|
+
message:
|
|
145
|
+
`Session ${sessionId} replayed from a different device and network ` +
|
|
146
|
+
`(${attacker.ip.intel?.country ?? attacker.ip.ip}) — no new login, no password change`,
|
|
147
|
+
timestamp: Date.now(),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const hijackedAction = await scoreAndCount({
|
|
151
|
+
ctx,
|
|
152
|
+
method: 'scoreAction',
|
|
153
|
+
payload: buildActionPayload(
|
|
154
|
+
options.targetUserId,
|
|
155
|
+
'view_balance',
|
|
156
|
+
{},
|
|
157
|
+
{
|
|
158
|
+
session_id: sessionId, // the one thing that did not change
|
|
159
|
+
device_fingerprint: attacker.device,
|
|
160
|
+
ip_address: attacker.ip.ip,
|
|
161
|
+
user_agent: attacker.userAgent,
|
|
162
|
+
}
|
|
163
|
+
),
|
|
164
|
+
results,
|
|
165
|
+
stage: 'hijacked-action',
|
|
166
|
+
ipIntel: attacker.ip.intel,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
results.hijackedActionDecision = hijackedAction?.decision ?? null;
|
|
170
|
+
if (hijackedAction?.decision === 'BLOCK' || hijackedAction?.decision === 'STEP_UP') {
|
|
171
|
+
results.hijackDetected = true;
|
|
172
|
+
emit({
|
|
173
|
+
type: 'milestone',
|
|
174
|
+
message: `Session continuity break detected at the first hijacked action (${hijackedAction.decision})`,
|
|
175
|
+
timestamp: Date.now(),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Stage 4: cash out on the stolen session ─────────────────────────
|
|
180
|
+
if (!options.withdrawAfterHijack || signal.aborted) {
|
|
181
|
+
return finalise(results, results.hijackDetected);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const withdrawal = await scoreAndCount({
|
|
185
|
+
ctx,
|
|
186
|
+
method: 'scoreWithdrawal',
|
|
187
|
+
payload: buildWithdrawalPayload(
|
|
188
|
+
options.targetUserId,
|
|
189
|
+
{ amount: options.amountMinor },
|
|
190
|
+
{
|
|
191
|
+
session_id: sessionId,
|
|
192
|
+
device_fingerprint: attacker.device,
|
|
193
|
+
ip_address: attacker.ip.ip,
|
|
194
|
+
}
|
|
195
|
+
),
|
|
196
|
+
results,
|
|
197
|
+
stage: 'hijacked-withdrawal',
|
|
198
|
+
ipIntel: attacker.ip.intel,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
results.withdrawalDecision = withdrawal?.decision ?? null;
|
|
202
|
+
if (withdrawal?.decision === 'BLOCK' || withdrawal?.decision === 'STEP_UP') {
|
|
203
|
+
results.hijackDetected = true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (withdrawal?.decision === 'ALLOW') {
|
|
207
|
+
emit({
|
|
208
|
+
type: 'milestone',
|
|
209
|
+
message:
|
|
210
|
+
`Withdrawal of NGN ${(options.amountMinor / 100).toLocaleString()} allowed on a session ` +
|
|
211
|
+
'whose device and network both changed mid-flight — session continuity is not being scored',
|
|
212
|
+
timestamp: Date.now(),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return finalise(results, results.hijackDetected, options.amountMinor);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
function pickIp(persona) {
|
|
221
|
+
if (!persona || persona === 'random') return { ip: randomIp(), intel: undefined };
|
|
222
|
+
const { ip, ...intel } = pickDemoIp(persona);
|
|
223
|
+
return { ip, intel };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function finalise(results, detected, amountMinor = 0) {
|
|
227
|
+
const succeeded = results.withdrawalDecision === 'ALLOW';
|
|
228
|
+
return {
|
|
229
|
+
...results,
|
|
230
|
+
hijackDetected: detected,
|
|
231
|
+
attackerSucceeded: succeeded,
|
|
232
|
+
estimatedLossKobo: succeeded ? amountMinor : 0,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scenario: sim-swap-takeover
|
|
3
|
+
*
|
|
4
|
+
* The attacker does not break the authentication — they redirect it. By
|
|
5
|
+
* socially engineering or bribing a telco into porting the victim's number to a
|
|
6
|
+
* SIM they control, every SMS OTP now arrives on the attacker's handset. Then
|
|
7
|
+
* they walk through the front door: password reset, OTP confirmed, login,
|
|
8
|
+
* withdrawal.
|
|
9
|
+
*
|
|
10
|
+
* Every individual step passes. The password reset is confirmed by a real OTP.
|
|
11
|
+
* The login satisfies two factors. The withdrawal comes from an authenticated,
|
|
12
|
+
* verified session. The only thing wrong is that the second factor stopped
|
|
13
|
+
* belonging to the customer a few minutes ago.
|
|
14
|
+
*
|
|
15
|
+
* Defensive systems should catch this via:
|
|
16
|
+
* - Telco porting check (number ported within N days → distrust SMS OTP)
|
|
17
|
+
* - OTP-to-new-device (second factor confirmed on an unseen handset)
|
|
18
|
+
* - Login-immediately-after-port heuristic
|
|
19
|
+
* - Step-up escalation (fall back to a factor the attacker cannot port)
|
|
20
|
+
*
|
|
21
|
+
* This scenario is the strongest argument for treating SMS OTP as a signal
|
|
22
|
+
* rather than proof. A system relying on OTP alone will pass every stage.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { sleep } from '../utils/sleep.js';
|
|
26
|
+
import {
|
|
27
|
+
buildLoginPayload,
|
|
28
|
+
buildWithdrawalPayload,
|
|
29
|
+
buildActionPayload,
|
|
30
|
+
newDeviceFingerprint,
|
|
31
|
+
newSessionId,
|
|
32
|
+
} from '../utils/faker-helpers.js';
|
|
33
|
+
import { newResults, scoreAndCount } from '../utils/results.js';
|
|
34
|
+
import { simulatedClock, humaniseMs } from '../utils/clock.js';
|
|
35
|
+
import { randomPhoneNumber, randomIp, pickDemoIp } from '../utils/random.js';
|
|
36
|
+
|
|
37
|
+
export default {
|
|
38
|
+
id: 'sim-swap-takeover',
|
|
39
|
+
name: 'SIM swap takeover',
|
|
40
|
+
description:
|
|
41
|
+
"The victim's number is ported to the attacker's SIM, redirecting OTPs; the attacker then resets the password, logs in, and withdraws.",
|
|
42
|
+
|
|
43
|
+
defaultOptions: {
|
|
44
|
+
targetUserId: null, // required
|
|
45
|
+
minutesSincePort: 20, // how recently the number was ported
|
|
46
|
+
amountMinor: 40000000, // NGN 400,000 — SIM swaps target high-value accounts
|
|
47
|
+
reportPortEvent: true, // tell the target about the port, if it accepts one
|
|
48
|
+
attemptPasswordReset: true,
|
|
49
|
+
ipPersona: 'domestic', // SIM swappers are usually in-country
|
|
50
|
+
delayMs: 200,
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
async run(ctx) {
|
|
54
|
+
const { options, emit, signal } = ctx;
|
|
55
|
+
|
|
56
|
+
if (!options.targetUserId) {
|
|
57
|
+
throw new Error('sim-swap-takeover requires options.targetUserId');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const results = newResults({
|
|
61
|
+
stages: [], // ordered record of what happened at each step
|
|
62
|
+
blockedAtStage: null,
|
|
63
|
+
resetDecision: null,
|
|
64
|
+
loginDecision: null,
|
|
65
|
+
withdrawalDecision: null,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const clock = simulatedClock({ start: Date.now() });
|
|
69
|
+
const portedAt = new Date(clock.epoch() - options.minutesSincePort * 60_000).toISOString();
|
|
70
|
+
|
|
71
|
+
const attacker = {
|
|
72
|
+
device: newDeviceFingerprint(), // a handset the account has never seen
|
|
73
|
+
ip: pickIp(options.ipPersona),
|
|
74
|
+
session: newSessionId('swap'),
|
|
75
|
+
phone: randomPhoneNumber(),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const record = (stage, decision) => {
|
|
79
|
+
results.stages.push({ stage, decision: decision ?? 'ERROR' });
|
|
80
|
+
if ((decision === 'BLOCK' || decision === 'STEP_UP') && results.blockedAtStage == null) {
|
|
81
|
+
results.blockedAtStage = stage;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
emit({
|
|
86
|
+
type: 'milestone',
|
|
87
|
+
message:
|
|
88
|
+
`Number ported to the attacker's SIM ${humaniseMs(options.minutesSincePort * 60_000)} ago — ` +
|
|
89
|
+
'all SMS OTPs now arrive on their handset',
|
|
90
|
+
timestamp: Date.now(),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ── Stage 0: tell the target the number was ported ──────────────────
|
|
94
|
+
// A system integrated with a telco porting feed would already know. Systems
|
|
95
|
+
// that accept the signal from the fintech need to be told.
|
|
96
|
+
if (options.reportPortEvent && typeof ctx.target.reportEvent === 'function') {
|
|
97
|
+
try {
|
|
98
|
+
await ctx.target.reportEvent('sim-swap', {
|
|
99
|
+
user_id: options.targetUserId,
|
|
100
|
+
ported_at: portedAt,
|
|
101
|
+
new_msisdn: attacker.phone,
|
|
102
|
+
metadata: { _simulated: true },
|
|
103
|
+
});
|
|
104
|
+
} catch {
|
|
105
|
+
// Not every target models this. The attack proceeds either way.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Stage 1: password reset, confirmed by the redirected OTP ────────
|
|
110
|
+
if (options.attemptPasswordReset) {
|
|
111
|
+
const reset = await scoreAndCount({
|
|
112
|
+
ctx,
|
|
113
|
+
method: 'scoreAction',
|
|
114
|
+
payload: buildActionPayload(
|
|
115
|
+
options.targetUserId,
|
|
116
|
+
'password_reset',
|
|
117
|
+
{
|
|
118
|
+
confirmed_by: 'sms_otp',
|
|
119
|
+
otp_delivered_to: attacker.phone,
|
|
120
|
+
msisdn_ported_at: portedAt,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
session_id: attacker.session,
|
|
124
|
+
device_fingerprint: attacker.device,
|
|
125
|
+
ip_address: attacker.ip.ip,
|
|
126
|
+
timestamp: clock.iso(),
|
|
127
|
+
}
|
|
128
|
+
),
|
|
129
|
+
results,
|
|
130
|
+
stage: 'password-reset',
|
|
131
|
+
ipIntel: attacker.ip.intel,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
results.resetDecision = reset?.decision ?? null;
|
|
135
|
+
record('password-reset', reset?.decision);
|
|
136
|
+
|
|
137
|
+
if (reset?.decision === 'BLOCK') {
|
|
138
|
+
emit({
|
|
139
|
+
type: 'milestone',
|
|
140
|
+
message: 'Password reset blocked — the port was treated as disqualifying the OTP',
|
|
141
|
+
timestamp: Date.now(),
|
|
142
|
+
});
|
|
143
|
+
return finalise(results, options, clock);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
clock.advance(3 * 60_000);
|
|
147
|
+
try {
|
|
148
|
+
await sleep(options.delayMs, signal);
|
|
149
|
+
} catch {
|
|
150
|
+
return finalise(results, options, clock);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Stage 2: login from the attacker's handset ──────────────────────
|
|
155
|
+
const login = await scoreAndCount({
|
|
156
|
+
ctx,
|
|
157
|
+
method: 'scoreLogin',
|
|
158
|
+
payload: buildLoginPayload(options.targetUserId, {
|
|
159
|
+
session_id: attacker.session,
|
|
160
|
+
device_fingerprint: attacker.device,
|
|
161
|
+
ip_address: attacker.ip.ip,
|
|
162
|
+
timestamp: clock.iso(),
|
|
163
|
+
metadata: {
|
|
164
|
+
login_method: 'sms_otp',
|
|
165
|
+
platform: 'android',
|
|
166
|
+
msisdn_ported_at: portedAt,
|
|
167
|
+
_simulated: true,
|
|
168
|
+
},
|
|
169
|
+
}),
|
|
170
|
+
results,
|
|
171
|
+
stage: 'login',
|
|
172
|
+
ipIntel: attacker.ip.intel,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
results.loginDecision = login?.decision ?? null;
|
|
176
|
+
record('login', login?.decision);
|
|
177
|
+
|
|
178
|
+
if (login?.decision === 'BLOCK') {
|
|
179
|
+
emit({
|
|
180
|
+
type: 'milestone',
|
|
181
|
+
message: 'Login blocked — new device plus a recent port was enough',
|
|
182
|
+
timestamp: Date.now(),
|
|
183
|
+
});
|
|
184
|
+
return finalise(results, options, clock);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
clock.advance(2 * 60_000);
|
|
188
|
+
try {
|
|
189
|
+
await sleep(options.delayMs, signal);
|
|
190
|
+
} catch {
|
|
191
|
+
return finalise(results, options, clock);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Stage 3: immediate high-value withdrawal ────────────────────────
|
|
195
|
+
emit({
|
|
196
|
+
type: 'milestone',
|
|
197
|
+
message: `Withdrawing NGN ${(options.amountMinor / 100).toLocaleString()} minutes after the port`,
|
|
198
|
+
timestamp: Date.now(),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const withdrawal = await scoreAndCount({
|
|
202
|
+
ctx,
|
|
203
|
+
method: 'scoreWithdrawal',
|
|
204
|
+
payload: buildWithdrawalPayload(
|
|
205
|
+
options.targetUserId,
|
|
206
|
+
{ amount: options.amountMinor },
|
|
207
|
+
{
|
|
208
|
+
session_id: attacker.session,
|
|
209
|
+
device_fingerprint: attacker.device,
|
|
210
|
+
ip_address: attacker.ip.ip,
|
|
211
|
+
timestamp: clock.iso(),
|
|
212
|
+
}
|
|
213
|
+
),
|
|
214
|
+
results,
|
|
215
|
+
stage: 'withdrawal',
|
|
216
|
+
ipIntel: attacker.ip.intel,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
results.withdrawalDecision = withdrawal?.decision ?? null;
|
|
220
|
+
record('withdrawal', withdrawal?.decision);
|
|
221
|
+
|
|
222
|
+
if (withdrawal?.decision === 'ALLOW') {
|
|
223
|
+
emit({
|
|
224
|
+
type: 'milestone',
|
|
225
|
+
message:
|
|
226
|
+
'Withdrawal allowed. Every stage was confirmed by an OTP the attacker received — ' +
|
|
227
|
+
'SMS as proof of identity is the whole vulnerability here.',
|
|
228
|
+
timestamp: Date.now(),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return finalise(results, options, clock);
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
function pickIp(persona) {
|
|
237
|
+
if (!persona || persona === 'random') return { ip: randomIp(), intel: undefined };
|
|
238
|
+
const { ip, ...intel } = pickDemoIp(persona);
|
|
239
|
+
return { ip, intel };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function finalise(results, options, clock) {
|
|
243
|
+
const succeeded = results.withdrawalDecision === 'ALLOW';
|
|
244
|
+
return {
|
|
245
|
+
...results,
|
|
246
|
+
attackerSucceeded: succeeded,
|
|
247
|
+
estimatedLossKobo: succeeded ? options.amountMinor : 0,
|
|
248
|
+
// Which stage first pushed back. `null` means the whole chain succeeded.
|
|
249
|
+
blockedAtStage: results.blockedAtStage,
|
|
250
|
+
simulatedTime: true,
|
|
251
|
+
simulatedSpan: humaniseMs(clock.elapsedMs()),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scenario: slow-drain
|
|
3
|
+
*
|
|
4
|
+
* The patient version of an account takeover. Instead of emptying an account in
|
|
5
|
+
* minutes, the attacker withdraws small amounts spaced far enough apart that no
|
|
6
|
+
* short-window velocity rule ever trips. Over days the account is emptied
|
|
7
|
+
* anyway.
|
|
8
|
+
*
|
|
9
|
+
* This is the scenario that separates systems with a real baseline from systems
|
|
10
|
+
* with a counter. Any engine can catch five withdrawals in a minute. Catching
|
|
11
|
+
* twenty withdrawals over four days — each individually ordinary, but ten times
|
|
12
|
+
* the account's normal weekly outflow — requires knowing what normal is.
|
|
13
|
+
*
|
|
14
|
+
* Defensive systems should catch this via:
|
|
15
|
+
* - Multi-window velocity (hourly AND daily AND weekly counters)
|
|
16
|
+
* - Baseline deviation (this user does not usually behave like this)
|
|
17
|
+
* - Cumulative amount rules (total moved since a trust event)
|
|
18
|
+
* - Payee-diversity anomalies (many new destinations over a long window)
|
|
19
|
+
*
|
|
20
|
+
* ── On simulated time ─────────────────────────────────────────────────
|
|
21
|
+
* A real slow drain takes days, so this scenario advances the `timestamp` field
|
|
22
|
+
* rather than sleeping. That is faithful only if the target derives its windows
|
|
23
|
+
* from the event timestamp. A system that stamps arrival time server-side will
|
|
24
|
+
* see one compressed burst and block it for the wrong reason — a pass here
|
|
25
|
+
* would be meaningless. The report sets `simulatedTime: true` so this is never
|
|
26
|
+
* silently assumed.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { sleep } from '../utils/sleep.js';
|
|
30
|
+
import {
|
|
31
|
+
buildWithdrawalPayload,
|
|
32
|
+
newDeviceFingerprint,
|
|
33
|
+
newSessionId,
|
|
34
|
+
} from '../utils/faker-helpers.js';
|
|
35
|
+
import { newResults, scoreAndCount } from '../utils/results.js';
|
|
36
|
+
import { simulatedClock, humaniseMs } from '../utils/clock.js';
|
|
37
|
+
import { randomIp, randomInt } from '../utils/random.js';
|
|
38
|
+
|
|
39
|
+
export default {
|
|
40
|
+
id: 'slow-drain',
|
|
41
|
+
name: 'Slow drain below velocity thresholds',
|
|
42
|
+
description:
|
|
43
|
+
'Withdrawals individually below every short-window threshold, spaced over days, that collectively empty the account.',
|
|
44
|
+
|
|
45
|
+
defaultOptions: {
|
|
46
|
+
targetUserId: null, // required
|
|
47
|
+
withdrawalCount: 20,
|
|
48
|
+
amountMinor: 2500000, // NGN 25,000 — deliberately modest
|
|
49
|
+
amountJitterMinor: 500000, // vary amounts so they don't look scripted
|
|
50
|
+
intervalMinutes: 300, // 5 simulated hours between withdrawals
|
|
51
|
+
sameDevice: true, // a patient attacker keeps one stable device
|
|
52
|
+
delayMs: 60, // wall-clock pacing only
|
|
53
|
+
maxTotalAttempts: 100,
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
async run(ctx) {
|
|
57
|
+
const { options, emit, signal } = ctx;
|
|
58
|
+
|
|
59
|
+
if (!options.targetUserId) {
|
|
60
|
+
throw new Error('slow-drain requires options.targetUserId');
|
|
61
|
+
}
|
|
62
|
+
if (options.intervalMinutes <= 0) {
|
|
63
|
+
throw new Error('slow-drain requires options.intervalMinutes > 0');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const results = newResults({
|
|
67
|
+
withdrawalsAttempted: 0,
|
|
68
|
+
drainedMinor: 0,
|
|
69
|
+
firstBlockedAtWithdrawal: null,
|
|
70
|
+
firstStepUpAtWithdrawal: null,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const clock = simulatedClock({
|
|
74
|
+
start: Date.now(),
|
|
75
|
+
stepMs: options.intervalMinutes * 60_000,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// A patient attacker is not rotating infrastructure — that would create the
|
|
79
|
+
// very novelty signals they are trying to avoid.
|
|
80
|
+
const device = options.sameDevice ? newDeviceFingerprint() : null;
|
|
81
|
+
const ip = options.sameDevice ? randomIp() : null;
|
|
82
|
+
const sessionId = newSessionId('drain');
|
|
83
|
+
|
|
84
|
+
const totalSpanMs = options.withdrawalCount * options.intervalMinutes * 60_000;
|
|
85
|
+
|
|
86
|
+
emit({
|
|
87
|
+
type: 'milestone',
|
|
88
|
+
message:
|
|
89
|
+
`Draining NGN ${(options.amountMinor / 100).toLocaleString()} at a time, ` +
|
|
90
|
+
`every ${humaniseMs(options.intervalMinutes * 60_000)}, ` +
|
|
91
|
+
`over a simulated ${humaniseMs(totalSpanMs)}`,
|
|
92
|
+
timestamp: Date.now(),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
emit({
|
|
96
|
+
type: 'milestone',
|
|
97
|
+
message:
|
|
98
|
+
'Timestamps are simulated. If the target derives its velocity windows from ' +
|
|
99
|
+
'server arrival time rather than the event timestamp, this run measures a burst, not a slow drain.',
|
|
100
|
+
timestamp: Date.now(),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
for (let i = 0; i < options.withdrawalCount; i++) {
|
|
104
|
+
if (signal.aborted) break;
|
|
105
|
+
if (results.attempts >= options.maxTotalAttempts) break;
|
|
106
|
+
|
|
107
|
+
const jitter = options.amountJitterMinor
|
|
108
|
+
? randomInt(-options.amountJitterMinor, options.amountJitterMinor)
|
|
109
|
+
: 0;
|
|
110
|
+
const amount = Math.max(1, options.amountMinor + jitter);
|
|
111
|
+
|
|
112
|
+
const overrides = { timestamp: clock.iso(), session_id: sessionId };
|
|
113
|
+
if (device) overrides.device_fingerprint = device;
|
|
114
|
+
if (ip) overrides.ip_address = ip;
|
|
115
|
+
|
|
116
|
+
const response = await scoreAndCount({
|
|
117
|
+
ctx,
|
|
118
|
+
method: 'scoreWithdrawal',
|
|
119
|
+
payload: buildWithdrawalPayload(options.targetUserId, { amount }, overrides),
|
|
120
|
+
results,
|
|
121
|
+
stage: 'withdrawal',
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
results.withdrawalsAttempted++;
|
|
125
|
+
|
|
126
|
+
if (response?.decision === 'ALLOW') {
|
|
127
|
+
results.drainedMinor += amount;
|
|
128
|
+
} else if (response?.decision === 'BLOCK' && results.firstBlockedAtWithdrawal == null) {
|
|
129
|
+
results.firstBlockedAtWithdrawal = i + 1;
|
|
130
|
+
emit({
|
|
131
|
+
type: 'milestone',
|
|
132
|
+
message:
|
|
133
|
+
`Blocked at withdrawal ${i + 1} of ${options.withdrawalCount} — ` +
|
|
134
|
+
`NGN ${(results.drainedMinor / 100).toLocaleString()} already gone ` +
|
|
135
|
+
`over ${humaniseMs(clock.elapsedMs())}`,
|
|
136
|
+
timestamp: Date.now(),
|
|
137
|
+
});
|
|
138
|
+
} else if (response?.decision === 'STEP_UP' && results.firstStepUpAtWithdrawal == null) {
|
|
139
|
+
results.firstStepUpAtWithdrawal = i + 1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
clock.advance();
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await sleep(options.delayMs, signal);
|
|
146
|
+
} catch {
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (results.firstBlockedAtWithdrawal == null) {
|
|
152
|
+
emit({
|
|
153
|
+
type: 'milestone',
|
|
154
|
+
message:
|
|
155
|
+
`Never blocked. NGN ${(results.drainedMinor / 100).toLocaleString()} drained across ` +
|
|
156
|
+
`${results.withdrawalsAttempted} withdrawals — short-window velocity alone does not catch this.`,
|
|
157
|
+
timestamp: Date.now(),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
...results,
|
|
163
|
+
estimatedLossKobo: results.drainedMinor,
|
|
164
|
+
attackerSucceeded: results.firstBlockedAtWithdrawal == null && results.drainedMinor > 0,
|
|
165
|
+
simulatedTime: true,
|
|
166
|
+
simulatedSpan: humaniseMs(clock.elapsedMs()),
|
|
167
|
+
// How much got out before anything fired — the number that matters here.
|
|
168
|
+
drainedBeforeFirstBlockMinor: results.drainedMinor,
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
};
|