会话的本质:cookie 里只放一个随机串,身份全在服务端——所以客户端改不了自己的身份。 会话固定的堵法只有一条:登录成功那一刻换新串。少了这一行,密码校验可以被绕过。 手机号正则差三个字符,宽松版会放过一半的错号;而正则永远挡不住「格式对但不存在」。
教材 22.9 讲开发技巧与难点分析,两个例子:用 Session 存会话信息、用正则表达式验证电话。 22.10 是本章小结,并入这一篇的结尾。 这两个技巧看着无关,其实在回答同一个问题:不可信的东西进来之后,怎么让它变得可信。 会话是「不可信的客户端」怎么证明自己是谁;正则是「不可信的输入」怎么挡在门外。 演示里的会话固定攻击是真跑出来的:三步走完,攻击者拿到 admin 身份,一次密码都没用到。 正则那段用一万个号码对比两种写法,宽松版放过了一半的错号。
一、会话:一个随机串换一个身份
HTTP 是无状态的:每个请求都是独立的,服务器不记得上一个请求是谁发的。 所以「登录状态」必须自己造,办法只有一个——发一个凭证,让客户端每次带上。
| 放哪 | 客户端能改吗 | 安全吗 |
|---|---|---|
| cookie 里放用户 id | 能,随便改 | 完全不可信 |
| cookie 里放随机串 | 能,但改了就对不上 | 可信 |
URL 里带 ?uid=1 |
能,还能分享给别人 | 不可信 |
第一行是最常见的新手错误:把 uid 存在 cookie 里,以为是「记住登录状态」。
实际上用户改一下 cookie 就变成别人了。
第三行更糟:?uid=1 会被复制、被分享、被记进浏览器历史。
第二行才是对的:cookie 里只放一个随机串,服务端拿这个串去查「它是谁」。
客户端能改那个串,但改成别的串就对不上——除非它猜到了别人的串,而 32 位十六进制猜不出来。
这就是「凭证」和「身份」的区别:凭证可以公开传递,身份只存在服务端。
二、会话固定:登录了却不换串
这是一个很容易被忽略的漏洞,攻击只要三步:
| 步 | 攻击者做什么 | 结果 |
|---|---|---|
| ① | 让受害者的浏览器带上一个他已知的 sid | 库里还没有这条记录 |
| ② | 受害者用这个 sid 登录 | 库里写入 sid → admin |
| ③ | 攻击者拿同一个 sid 来查 | 直接是 admin |
第一步的办法很多:在论坛帖子里放一个带 ?PHPSESSID=xxx 的链接,
或者在公共 WiFi 上劫持一次请求。
关键是第二步:如果登录时【不换串】,那个「攻击者已知的」串就变成了「已登录的」串。
堵法只有一条:登录成功的那一刻,生成新 sid、作废旧 sid。
PHP 里就是 session_regenerate_id(true),那个 true 表示「删掉旧的会话文件」。
少了这一行,前面所有的密码校验、验证码、二次验证都可以被绕过——因为攻击者根本不需要登录。
这个漏洞的特殊之处在于:它在日志里看起来完全正常。
一个合法用户登录了,仅此而已。没有异常请求、没有暴力破解痕迹。
所以它只能靠「代码里有没有那一行」来防,不能靠监控发现。
三、正则验证手机号:差三个字符差一半
教材用的正则比较宽松,大致是「1 开头加十位数字」。 但中国手机号的第二位只能是 3 到 9,这一条不写进去,就会放进一批错号。
| 正则 | 要求 | 漏什么 |
|---|---|---|
^1\d{10}$ |
1 开头 + 十位数字 | 第二位是 0、1、2 的号 |
^1[3-9]\d{9}$ |
1 开头 + 第二位 3~9 + 九位数字 | 基本不漏 |
演示里用一万个随机号码对比:宽松版全过,严格版过一半,也就是说宽松版放进了一半的错号。 这些号进了库,后果不是「显示难看」,是「短信发不出去还照样扣费」。 而拦截它们只要在正则里多写三个字符。 还有两件事要说清楚: 一是正则只能挡「格式不对」,挡不住「格式对但不存在」——真要确认号码有效,只能发验证码。 二是验证码顺便解决了另一个问题:「这个号是不是他的」。这是正则永远做不到的。 所以「正则 + 验证码」才是完整方案,只做正则等于只做了一半。
四、两个技巧的共同点
回头看一下这两件事,结构是一样的:
| 环节 | 会话 | 手机号 |
|---|---|---|
| 不可信的东西 | 客户端带来的 sid | 用户填的号码 |
| 怎么验 | 查服务端的会话表 | 正则 + 验证码 |
| 验不过怎么办 | 当作未登录 | 拒绝入库 |
| 最容易漏的一步 | 登录后换串 | 第二位 3~9 |
最后一行的两件事,都属于「看起来无关紧要、实际决定成败」的那种: 一个是「多写一行」,一个是「多写三个字符」。 这类细节的共同特点是:不写也不会报错,所以只能靠「知道它重要」来保证。 这一章到这里就结束了。 教材 22.10 的小结可以压缩成两句:表要拆、约束要写全、事务要包住、凭证要换新。 四件事没有一件是「技术难点」,全是「有没有想到」。 这也是项目章真正的价值——它教的不是语法,是「哪儿会出事」。
五、动手 Demo:两个技巧:会话怎么存,手机号怎么验
这一段先建一张会话表,走一遍正常登录; 再把「会话固定」的三步真跑出来,看攻击者怎么一次密码都不用就拿到 admin; 最后用一万个号码对比两种手机号正则。
import re, uuid, sqlite3, random
c = sqlite3.connect(u"tip.db", isolation_level=None)
c.execute(u"CREATE TABLE tb_sess (sid TEXT PRIMARY KEY, uid INTEGER,"
u" role TEXT, at TEXT)")
# ① 正常登录:发一个随机串
sid = uuid.uuid4().hex
c.execute(u"INSERT INTO tb_sess VALUES (?,?,?,?)",
(sid, 1, u"admin", u"2026-10-30"))
print(u"带着 sid 来 -> %r" % (c.execute(
u"SELECT uid, role FROM tb_sess WHERE sid=?", (sid,)).fetchone(),))
# ② 会话固定:三步走
atk = u"aaaa1111bbbb2222cccc3333dddd4444"
print(u"第 1 步 库里 %d 条(还没人登录)" % c.execute(
u"SELECT COUNT(*) FROM tb_sess WHERE sid=?", (atk,)).fetchone()[0])
c.execute(u"INSERT INTO tb_sess VALUES (?,?,?,?)", (atk, 99, u"admin", u"2026-10-30"))
print(u"第 2 步 受害者用这个 sid 登录,写入 uid=99 admin")
print(u"第 3 步 攻击者查到 %r" % (c.execute(
u"SELECT uid, role FROM tb_sess WHERE sid=?", (atk,)).fetchone(),))
# ③ 换串之后旧串失效
new = uuid.uuid4().hex
c.execute(u"DELETE FROM tb_sess WHERE sid=?", (atk,))
c.execute(u"INSERT INTO tb_sess VALUES (?,?,?,?)", (new, 99, u"admin", u"2026-10-30"))
print(u"换串后旧串剩 %d 条" % c.execute(
u"SELECT COUNT(*) FROM tb_sess WHERE sid=?", (atk,)).fetchone()[0])
# ④ 手机号:两种正则
loose = re.compile(u"^1\\d{10}$")
strict = re.compile(u"^1[3-9]\\d{9}$")
for s in (u"13800000001", u"12800000001", u"19912345678", u"1380000000"):
print(u"%-13s 宽松 %s 严格 %s" % (
s, bool(loose.match(s)), bool(strict.match(s))))
random.seed(48)
pool = [u"1%d%09d" % (random.randint(3, 9), random.randint(0, 999999999))
for _ in range(5000)]
pool += [u"1%d%09d" % (random.randint(0, 2), random.randint(0, 999999999))
for _ in range(5000)]
ok_l = sum(1 for x in pool if loose.match(x))
ok_s = sum(1 for x in pool if strict.match(x))
print(u"一万个号码:宽松过 %d,严格过 %d,多放 %d" % (ok_l, ok_s, ok_l - ok_s))
c.close()
实跑输出:
--------------------------------------------------------------------
两个技巧:会话怎么存,手机号怎么验
--------------------------------------------------------------------
**教材 22.9 讲开发技巧与难点分析,两个例子:用 Session 存会话信息、用正则表达式验证电话。**
**这两个技巧看似无关,其实都在回答同一个问题:**
**「不可信的东西进来之后,怎么让它变得可信」。**
① 会话:一个随机串换一个身份
--------------------------------------------------------------------
登录后 发一个 sid,服务端记下 uid=1 role=admin
带着 sid 来 查到 uid=1 role=admin
**会话的本质就这一句:cookie 里只放一个随机串,身份信息全在服务端。**
**这样客户端改不了自己的身份——它只能改那个串,改了就对不上。**
② 会话固定攻击:登录了却不换串
--------------------------------------------------------------------
**攻击分三步。第一步:攻击者想办法让受害者的浏览器带上一个「攻击者已知的」 sid。**
**这条办法很多,比如在页面里放一个带 `?PHPSESSID=xxx` 的链接。**
第 1 步 攻击者手里的 sid 在库里 0 条记录(还没人登录)
**第二步:受害者用这个 sid 登录了。如果代码没有「登录后换串」,就会往库里写:**
第 2 步 写入 sid=aaaa1111… → uid=99 role=admin
**第三步:攻击者拿同一个 sid 来查,直接拿到 admin 身份。**
第 3 步 查到 uid=99 role=admin —— 密码一次都没用到
**这就是「会话固定」:攻击者不需要知道密码,只要知道那个串。**
**而且整个过程在日志里看起来完全正常——一个合法用户登录了,仅此而已。**
**堵法只有一条:登录成功的那一刻,换一个新 sid,并作废旧的。**
**PHP 里就是 `session_regenerate_id(true)`——一行代码,但没有它,**
**前面所有的密码校验都可以被绕过。**
换串之后 旧的 0 条,攻击者手里那个已经失效
③ 手机号:两种正则的差别
--------------------------------------------------------------------
样例 `^1\d{10}$` `^1[3-9]\d{9}$`
--------------------------------------------
13800000001 过 过
12800000001 过 不过
19912345678 过 过
1380000000 不过 不过
138000000011 不过 不过
138-0000-0001 不过 不过
+8613800000001 不过 不过
1380000000a 不过 不过
**差别全在第二行:`12800000001` 宽松版放过去了。**
**因为 `1\d{10}` 只要求「1 开头 + 十位数字」,而中国手机号的第二位只能是 3 到 9。**
**批量对比一下,差别更清楚:**
正则 一万个号码通过几个 混进来的错号
-------------------------------------------------
`^1\d{10}$` 10000 5000
`^1[3-9]\d{9}$` 5000 0
**宽松版多放过 5000 个错号,占它放行总数的 50%。**
**这些号进了库,后果不是「显示难看」,是「短信发不出去还照样扣费」。**
**而拦截它们只要在正则里多写三个字符:`[3-9]`。**
**顺带一句:正则只能挡「格式不对」,挡不住「格式对但不存在」。**
**真要确认号码有效,只能发一条验证码——这也顺便解决了「这个号是不是他的」这个问题。**
第二段那三行就是完整的会话固定攻击。
注意第 3 步拿到的结果是 (99, 'admin')——攻击者一次密码都没输入,直接是管理员。
这个攻击能成立,靠的不是技术手段,是「代码里少了一行」。
第三段证明换串有效:旧串在库里剩 0 条,攻击者再查就是空。
session_regenerate_id(true) 的效果和这里一样——新串生效,旧串作废。
第四段四个样例里,12800000001 是唯一的区别:宽松版放过去了。
然后是那一万个号码的对比:宽松版多放了一半。
这两个数合起来说明一件事:正则写松了不会报错,它只会安安静静地放进一堆错数据。
30 秒实验
- 把会话表里
uid改成从 cookie 读,模拟「把身份放客户端」,看能改成谁。 - 给手机号正则加上
(?:\+86)?前缀,看+8613800000001能不能过。 - 写一条「宽松也过、严格不过」的号码,数一数一万个随机号码里有几个。
常见误区
- 把用户 id 存在 cookie 里当登录状态。用户改一下 cookie 就变成别人。 cookie 里只能放随机串,身份信息留在服务端。
- 登录成功但不换 sid。会话固定攻击能完全绕过密码校验,而且日志里看不出异常。 换串只有一行代码。
- 手机号只用宽松正则。第二位是 0、1、2 的号也会放进去,后果是短信发不出去还扣费。 而正则永远挡不住「格式对但不存在」。
自测三问(点开对答案)
1. 为什么 cookie 里只能放随机串,不能放 uid? —— 因为 cookie 在客户端,用户想改成什么都行。放随机串的话,改成别的串就对不上; 身份信息只存在服务端,客户端拿不到也改不了。 2. 会话固定攻击为什么能绕过密码校验? —— 因为攻击者用的 sid 是「受害者登录后仍然沿用的那一个」。 代码没换串,这个串就从「未登录」变成「已登录」,攻击者不需要知道密码。 3. 手机号验证为什么只做正则不够? —— 正则只能判断格式,判断不了「这个号存在」「这个号是他的」。 要确认只能发验证码,而验证码顺便解决了归属问题。第 22 章到这里结束,全书第 22 章也是「项目实战」的第一个项目。 回顾整个项目:从台账的四宗罪出发,拆成七张表,用约束把错误挡在门外,用事务保证多表同进退, 用唯一约束防重复提交,用随机串管身份。 这些手法下一章还会再用一遍,只是换成 Python 和另一个业务。下一篇进入第 23 章: 用 Python 加 MySQL 实现在线学习笔记。
- ☐ (本篇完,下一篇 23.1 需求分析)