编码、散列和加密是三件不同的事
一个看上去被打乱的字符串,并不是一个受保护的字符串。有三种彼此独立的操作都会产生不可读的输出,而把它们混为一谈,正是真实安全漏洞的来源。
| 可逆吗? | 需要密钥吗? | 用来做什么 | |
|---|---|---|---|
| 编码 | 可逆,谁都能逆 | 不需要 | 让字节穿过一条无法原样承载它们的通道 |
| 散列 | 不可逆 | 不需要 | 指纹、完整性校验、查找用的键 |
| 加密 | 可逆,但要有密钥 | 需要 | 保密 |
编码是换一套字母表。Base64、百分号编码、十六进制和 HTML 实体只是把同样的信息换个写法,好让下游的解析器不被噎住。其中不涉及任何秘密,所以也没有什么需要保守。凯撒移位也属于这一类:凯撒密码工具能一次把 25 种移位全部暴力试出来,这相当准确地概括了固定替换能提供多少保护。散列把任意输入映射成一个定长的摘要,而且无法反向运行。加密是这三者中唯一提供机密性的,而且它总是牵涉到一个密钥。如果你指不出密钥在哪里,那就没有任何东西被加密。
Base64 及其代价
Base64 把三个字节(24 位)拆成四组六位,再把每一组写成一个来自 64 字符字母表的字符。三个字节写成四个字符,意味着输出会大三分之一。这在嵌入文件时影响最大:文件转 Base64 工具产出的是一个 data URL,于是一张 300 KB 的图片进入你的 HTML 时大约是 400 KB,而且每次加载页面都要重新下载一遍。
当输入不是三字节的整数倍时,最后一组会用 = 补齐:
"abc" -> YWJj (3 bytes, no padding)
"ab" -> YWI= (2 bytes, one =)
"a" -> YQ== (1 byte, two =)
标准字母表以 + 和 / 结尾,这在 URL 里是个问题,因为在表单数据里 + 会被解码成空格,而 / 是路径分隔符。所以还存在第二套字母表:Base64url 把 + 换成 -、把 / 换成 _,通常还会去掉补齐符。这就是为什么从 URL 里复制出来的令牌,有时在设为标准字母表的解码器里会失败。Base64 编码器两套都支持,而且能正确处理 Unicode,粗糙的实现往往做不到这一点:文本必须先做 UTF-8 编码,再进入 Base64 这一步。
以上没有一样是安全措施。某个产品在 URL 里称之为“加密”标识符的值,非常经常就是一个普通整数的 Base64,任何人都能在几秒内读出来、改掉、再编码回去。JSON Web Token 的载荷也是一样:JWT 解码器不用密钥就能读出来,因为根本没有什么东西被锁上。
百分号编码,以及该用哪个函数
百分号编码把一个字节替换成 % 加两位十六进制数字。它作用在字节而不是字符上,所以非 ASCII 文本要先做 UTF-8 编码:é 变成 %C3%A9。
非保留字符(A-Z、a-z、0-9、-、.、_、~)永远不需要编码。保留字符(: / ? # [ ] @ ! $ & ' ( ) * + , ; =)是赋予 URL 结构的分隔符,某个保留字符是否需要编码,取决于它此刻是被当作分隔符还是被当作数据。这正是那两个 JavaScript 函数的区别所在:
encodeURI("https://ex.com/s?q=cats & dogs")
// https://ex.com/s?q=cats%20&%20dogs the & still splits the query
encodeURIComponent("cats & dogs")
// cats%20%26%20dogs safe as a single value
encodeURI 用于把一整个 URL 变成合法的形式,所以它保留分隔符不动。encodeURIComponent 用于放进路径段或查询参数里的单个值,所以它会把分隔符编码掉。凡是你要插入进去的东西都用后者,另外注意它仍然不会去动 !、'、(、) 和 *。
然后是加号。以 application/x-www-form-urlencoded 发送的表单体把空格编码成 +,这个约定又渗进了查询字符串,所以多数服务器会把查询里的 + 解码成空格。字面的加号必须以 %2B 发送,这就是为什么 alex+billing@example.com 那么频繁地变成 alex billing@example.com,标签被毁掉了。相比之下,在路径段里 + 就只是一个加号,而空格是 %20。当一个值经受住了一轮往返却在下一轮崩掉时,URL 编码器能把这件事显示出来。
转义方式取决于值落在哪里
不存在一种统一的“适用于网页的转义”形式。上下文说了算。
| 上下文 | 正确处理方式 |
|---|---|
| HTML 文本 | 把 &、< 和 > 转成实体 |
| 带引号的属性 | 把 & 以及包住它的那种引号转成实体,并且属性一定要加引号 |
<script> 内部 | 用 JavaScript 的字符串转义,而不是实体 |
href 或 src 里的 URL | 先对值做百分号编码,再做属性转义,然后检查协议 |
| CSS 值 | 用 CSS 转义,而且绝不要用用户输入拼出 url() |
script 那一条最容易让人栽跟头。脚本内容不会被做实体解码,所以写在那里的实体会保持字面形式,而 HTML 解析器一遇到 </script 这个序列就会结束这个块,不管它在不在引号里:
<script>var s = "</script>";</script> <!-- block ends early -->
<script>var s = "<\/script>";</script> <!-- correct -->
对 URL 来说,光做转义也不够。一个以 javascript: 开头的值,无论编码得多么小心,被点击时都会执行,所以要拿协议去比对一份允许列表。HTML 实体工具覆盖了文本和属性这两种情况,也能把实体解码回来,这在某个 API 把东西双重转义成 &amp; 之后是常见的需求。
散列:用哪种算法,做什么用
| 算法 | 摘要长度 | 现状 |
|---|---|---|
| MD5 | 128 位 | 已被攻破。构造碰撞轻而易举 |
| SHA-1 | 160 位 | 已被攻破。选择前缀碰撞既现实又不贵 |
| SHA-256、SHA-512 | 256、512 位 | 用于完整性校验和签名没问题 |
| bcrypt、scrypt、Argon2 | 不固定 | 只用于密码 |
MD5 和 SHA-1 在抗碰撞性上失守,意思是攻击者可以构造出两个摘要相同的不同输入。凡是用散列值代表一份文档的场合,它们都不能用:签名、证书、内容寻址,以及对攻击者提供的任何内容做去重。两者在抗原像性上都还没被攻破,所以一个老旧的 MD5 校验和仍然能发现意外的损坏,但它们都不该出现在新的工作里。请用 SHA-256,散列生成器对文件和文本都能算。
密码是另一个问题,而快速散列恰恰因为它快而成了错误的工具。普通硬件每秒能做数十亿次 SHA-256 运算,所以一份泄露的 SHA-256 密码散列表,就是一份泄露的密码表。bcrypt、scrypt 和 Argon2id 是刻意做慢的,而且可调,带有一个工作因子(后两者还有内存开销),随着硬件进步你可以把它调高,并且它们各自都会把一个唯一的随机盐存进输出里。
散列也不是签名。要证明一条消息来自持有某个密钥的人,请用 HMAC-SHA-256,而不是把密钥和消息粘在一起做散列,后者存在长度扩展攻击的风险。
散列做不到什么
散列无法通过计算被逆推。但只要可能输入的集合小到可以枚举,它就能被搜索逆推:把每一个四位 PIN 都散列一遍,一万个摘要瞬间到手,一份泄露名单里的每个邮编、电话号码或地址也一样。把标识符散列一下,并不能让它匿名化。
加盐,也就是给每条记录存一个唯一的随机值并混进输入里,并不会让单个目标更难猜中,但它迫使攻击者必须逐条记录分别攻击,也让预计算的表变得一文不值。对于低熵的值,真正有帮助的那部分是胡椒(一个保存在数据库之外的秘密密钥)。
认出一个来路不明的字符串
| 形态 | 识别特征 | 示例 |
|---|---|---|
| 十六进制 | 只有 0-9a-f,长度为偶数。32 个字符是 MD5,40 个是 SHA-1,64 个是 SHA-256 | 5d41402abc4b2a76b9719d911017c592 |
| Base64 | 大小写混合并带 + 和 /,长度是 4 的倍数,可能以 = 或 == 结尾 | SGVsbG8sIHdvcmxkIQ== |
| Base64url | 同上,但用 - 和 _,通常不补齐 | eyJzdWIiOiIxMjM0NSJ9 |
| 百分号编码 | % 后面跟两位十六进制数字 | a%20b%26c |
| JWT | 三段用句点分隔的 Base64url,以 eyJ 开头 | eyJhbGciOi... |
| bcrypt | 正好 60 个字符,以 $2a$、$2b$ 或 $2y$ 加一个开销值开头 | $2b$12$... |
| Argon2 | $argon2id$v=19$m=...,t=...,p=...$salt$hash |
eyJ 值得记住:它是 {" 的 Base64,所以任何这样开头的片段都是一个编码过的 JSON 对象,不管它是不是令牌。
能成功解码并不能证明你猜对了,因为 Base64 会把任何输入都变成字节。要检查结果是不是像样的文本,或者是否以某个已知的文件签名开头:以 iVBORw0KGgo 开头的 Base64 是 PNG,/9j/ 是 JPEG,JVBERi0 是 PDF,UEsDB 是 zip。如果那些字节看起来什么都不像,就把输出送进文本转二进制工具直接读它的编码值,或者检查一下这个值是不是在做 Base64 编码之前先做过百分号编码,这是常见的双层包裹。