🛢️ 第22章 PHP+MySQL实现物流配送信息网
📄 一句话概括

会话的本质: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 秒实验

  1. 把会话表里 uid 改成从 cookie 读,模拟「把身份放客户端」,看能改成谁。
  2. 给手机号正则加上 (?:\+86)? 前缀,看 +8613800000001 能不能过。
  3. 写一条「宽松也过、严格不过」的号码,数一数一万个随机号码里有几个。

常见误区

  1. 把用户 id 存在 cookie 里当登录状态。用户改一下 cookie 就变成别人。 cookie 里只能放随机串,身份信息留在服务端。
  2. 登录成功但不换 sid。会话固定攻击能完全绕过密码校验,而且日志里看不出异常。 换串只有一行代码。
  3. 手机号只用宽松正则。第二位是 0、1、2 的号也会放进去,后果是短信发不出去还扣费。 而正则永远挡不住「格式对但不存在」。
自测三问(点开对答案) 1. 为什么 cookie 里只能放随机串,不能放 uid? —— 因为 cookie 在客户端,用户想改成什么都行。放随机串的话,改成别的串就对不上; 身份信息只存在服务端,客户端拿不到也改不了。 2. 会话固定攻击为什么能绕过密码校验? —— 因为攻击者用的 sid 是「受害者登录后仍然沿用的那一个」。 代码没换串,这个串就从「未登录」变成「已登录」,攻击者不需要知道密码。 3. 手机号验证为什么只做正则不够? —— 正则只能判断格式,判断不了「这个号存在」「这个号是他的」。 要确认只能发验证码,而验证码顺便解决了归属问题。

第 22 章到这里结束,全书第 22 章也是「项目实战」的第一个项目。 回顾整个项目:从台账的四宗罪出发,拆成七张表,用约束把错误挡在门外,用事务保证多表同进退, 用唯一约束防重复提交,用随机串管身份。 这些手法下一章还会再用一遍,只是换成 Python 和另一个业务。下一篇进入第 23 章: 用 Python 加 MySQL 实现在线学习笔记。

相关笔记