How a Midnight SMS Scam Revealed the Need for a Multi‑Layer Anti‑Abuse System
A night‑time SMS billing attack that drained ¥11,500 in two hours exposed flaws in a naïve Session‑based verification design, prompting a detailed, five‑layer defense architecture that combines gateway rate limiting, Redis token‑bucket controls, advanced captcha tracking, device fingerprinting, blacklist automation, and honey‑pot tactics to raise attack costs.
Incident Overview
Within two hours the SMS‑billing backend sent 230,000 verification messages, costing ¥11,500. Backend logs showed a flood of /api/send-sms calls, confirming that the system was being abused rather than crashing.
Why Storing Verification Codes in Session Is Not Secure
The root cause is a chain of missing controls:
┌────────────────────────────────────────────────────┐
│ Problem 1: No request rate limit │
│ → Attacker can send 1 000 requests per second │
├────────────────────────────────────────────────────┤
│ Problem 2: Session tightly coupled with SMS │
│ → Every HTTP call triggers a real SMS send │
├────────────────────────────────────────────────────┤
│ Problem 3: No front‑end human verification │
│ → Bots follow the exact same flow as humans │
├────────────────────────────────────────────────────┤
│ Problem 4: No phone‑number throttling │
│ → One number can be bombarded indefinitely │
├────────────────────────────────────────────────────┤
│ Problem 5: No monitoring/alerting │
│ → Two‑hour blind spot before detection │
└────────────────────────────────────────────────────┘A minimal attacker script demonstrates the vulnerability:
import requests
while True:
requests.post(
"https://mydomain/api/send-sms",
json={"phone": "13800138000"}
)Layered Anti‑Abuse Architecture
Layer 1 – Gateway Rate Limiting (coarse‑grained)
Apply IP‑based limits before traffic reaches business logic. Example Nginx configuration limits each IP to 2 requests per second and returns HTTP 429 on excess:
# Nginx configuration
limit_req_zone $binary_remote_addr zone=sms_api:10m rate=2r/s;
server {
location /api/send-sms {
limit_req zone=sms_api burst=5 nodelay;
limit_req_status 429;
}
}⚠️ Pure IP limiting can affect NAT users; finer controls belong to later layers.
Layer 2 – Redis Multi‑Dimensional Token Bucket (core defense)
Combine several dimensions to defeat proxy‑pool bypass:
IP level – max 5 requests per minute
Phone level – max 10 requests per day
Device fingerprint – max 3 requests per hour
Global API – max 500 requests per second (DDOS protection)
All dimensions must pass; any hit results in immediate rejection.
Redis Lua script (atomic token bucket) :
-- Token bucket Lua script
local key = KEYS[1]
local capacity = tonumber(ARGV[1]) -- bucket capacity
local rate = tonumber(ARGV[2]) -- refill rate (tokens/sec)
local now = tonumber(ARGV[3]) -- current timestamp (ms)
local bucket = redis.call('HMGET', key, 'tokens', 'last_time')
local tokens = tonumber(bucket[1]) or capacity
local last_time = tonumber(bucket[2]) or now
local elapsed = (now - last_time) / 1000
tokens = math.min(capacity, tokens + elapsed * rate)
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'last_time', now)
redis.call('EXPIRE', key, 3600)
return 1 -- ✅ allow
else
return 0 -- ❌ reject
endBusiness‑level invocation (Java):
public boolean checkRateLimit(String phone, String ip, String deviceId) {
// Phone dimension: max 10 per day
boolean phoneOk = limiter.isAllowed("sms:phone:" + phone, 10, 86400);
// IP dimension: max 20 per hour
boolean ipOk = limiter.isAllowed("sms:ip:" + ip, 20, 3600);
// Device dimension: max 3 per hour
boolean devOk = limiter.isAllowed("sms:dev:" + deviceId, 3, 3600);
return phoneOk && ipOk && devOk; // All must pass
}Layer 3 – Sliding Captcha with Full Trajectory Verification
Most implementations only verify the final puzzle position, which AI can solve in ~0.1 s. Adding trajectory analysis raises the cost dramatically.
Human vs. machine trajectory characteristics :
Shape: human motion is a wavy curve with natural jitter; bots produce straight or simple curves.
Speed: humans accelerate then decelerate; bots often use constant speed or abrupt changes.
Pauses: humans insert 100‑300 ms thinking pauses; bots have minimal or fixed delays.
Y‑axis deviation: humans exhibit small irregular vertical offsets; bots often have zero deviation.
Total duration: humans 300 ms‑2000 ms; bots frequently <200 ms.
Mouse events: dense, irregular for humans; sparse, evenly spaced for bots.
Front‑end collector (JavaScript) captures the full trajectory:
class SliderTracker {
constructor() {
this.trajectory = [];
this.startTime = null;
}
track(e) {
if (!this.startTime) this.startTime = Date.now();
this.trajectory.push({
x: e.clientX,
y: e.clientY,
t: Date.now() - this.startTime,
pressure: e.pressure || 0
});
}
getFingerprint() {
return {
trajectory: this.trajectory,
duration: Date.now() - this.startTime,
velocityVariance: this._calcVelocityVariance(),
yDeviation: this._calcYDeviation()
};
}
_calcVelocityVariance() {
const v = [];
for (let i = 1; i < this.trajectory.length; i++) {
const dt = this.trajectory[i].t - this.trajectory[i-1].t;
const dx = this.trajectory[i].x - this.trajectory[i-1].x;
if (dt > 0) v.push(dx / dt);
}
const mean = v.reduce((a,b) => a + b, 0) / v.length;
return v.reduce((a,b) => a + (b - mean) ** 2, 0) / v.length;
}
_calcYDeviation() {
// implementation omitted for brevity
}
}Back‑end scoring (Python) returns a confidence score between 0 (bot) and 1 (human):
def score_trajectory(data: dict) -> float:
"""Return human confidence: 0 = bot, 1 = human"""
score = 1.0
if data['velocityVariance'] < VARIANCE_THRESHOLD:
score *= 0.3
if data['yDeviation'] == 0:
score *= 0.1
if data['duration'] < 200:
score *= 0.2
if len(data['trajectory']) < 10:
score *= 0.4
return score # <0.5 block, 0.5‑0.8 secondary, >=0.8 allowLayer 4 – Device Fingerprint + Blacklist
IP can be rotated, but a composite device fingerprint (hash of multiple attributes) is harder to forge. A Redis‑backed blacklist automatically bans abusive identifiers.
class BlacklistManager:
def add(self, key: str, reason: str, ttl: int = 86400):
redis.setex(f"bl:{key}", ttl, json.dumps({"reason": reason, "ts": time.time()}))
def check(self, ip: str, phone: str, device: str) -> bool:
pipe = redis.pipeline()
pipe.exists(f"bl:ip:{ip}")
pipe.exists(f"bl:phone:{phone}")
pipe.exists(f"bl:dev:{device}")
return any(pipe.execute())
def auto_ban(self, ip: str, count_1h: int):
if count_1h > 50:
self.add(f"ip:{ip}", "high_freq", ttl=3600)
if count_1h > 200:
self.add(f"ip:{ip}", "severe", ttl=86400 * 7)Layer 5 – Business‑Level Honey‑Pot (soft intercept)
Hard rejections reveal the block to attackers, allowing them to switch tactics. Returning a fake success forces the attacker to waste time confirming the result.
def send_sms_guarded(phone: str, ip: str, device: str):
risk = calc_risk_score(phone, ip, device)
if risk > 0.9:
# High risk: silently drop, return fake success
log_attack(phone, ip, device)
return {"code": 200, "msg": "Verification code sent"}
elif risk > 0.6:
# Medium risk: throttle + manual flag
flag_suspicious(phone)
return real_send(phone, delay=2)
else:
# Normal flow
return real_send(phone)Attackers fear uncertainty more than outright rejection; honey‑pot responses dramatically raise their cost.
Effectiveness – Attack Cost Comparison
The goal is not to make attacks impossible but to make them unprofitable.
Common Pitfalls (5)
Pitfall 1: Assuming HTTPS Prevents Abuse – Encryption does not limit request rate.
Pitfall 2: Limiting Only by IP, Not by Phone Number – Proxy pools bypass IP limits; phone‑level throttling is essential.
Pitfall 3: Slider Checks Only Position, Not Trajectory – AI solves the puzzle instantly, but mimicking human motion is costly.
Pitfall 4: Setting Verification Code Expiry Too Long – Five minutes is sufficient; thirty minutes unnecessarily widens the attack window.
Pitfall 5: No Real‑Time Alerts – Delays beyond five minutes allow attackers to drain resources before detection.
Final Guidance
Security is never “finished”; it must evolve with attacker capabilities. Evaluate defenses by the cost an attacker must incur to bypass a layer. When the cost exceeds the reward, attacks naturally fade.
Ask not "Is this implementation safe?" but "What effort does an attacker need to get past this defense?"
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Java Web Project
Focused on Java backend technologies, trending internet tech, and the latest industry developments. The platform serves over 200,000 Java developers, inviting you to learn and exchange ideas together. Check the menu for Java learning resources.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
