执行 git log,每一行提交前面都有一串 40 位十六进制字符。执行 git rev-parse HEAD,得到的也是同样形式的字符串。这些就是 Git 的对象 ID,而它们正是用 SHA-1 算出来的。一个已经被证明存在实际碰撞的算法,为什么还被最流行的版本控制工具用着?
对象 ID 是什么
Git 的存储模型是一张内容寻址的表:所有数据都以「对象」的形式保存,每个对象有一个 ID,而这个 ID 就是对象内容的 SHA-1 摘要。
对象分几类。blob 保存文件内容,tree 保存目录结构,commit 保存一次提交的元信息(作者、时间、父提交、指向的 tree)。给一个 commit 算 ID 时,输入是这段结构化的文本,输出是 40 位十六进制。
内容寻址带来两个很有用的性质。第一,相同内容的文件只会存储一份,因为它们的 ID 相同,天然去重。第二,任何对历史内容的修改都会改变对应的 ID,进而改变所有后继提交的 ID,这让篡改历史变得极易被发现。
碰撞攻击对 Git 意味着什么
SHA-1 的碰撞意味着可以构造出两个内容不同但摘要相同的文件。如果攻击者能利用这一点,理论上可以让仓库接受一个与预期内容不同的对象。
但要注意 Git 的实际用法与单纯的「文件哈希校验」有差别。Git 在计算对象 ID 时,会把对象类型和长度一起拼进输入,而不是直接对文件内容算哈希。这种「加前缀」的做法提高了构造碰撞的难度,因为攻击者需要构造的不仅是内容相同,还要在带前缀的情况下仍然碰撞。
更重要的是,实际攻击 Git 仓库还需要让碰撞对象进入他人的历史,这涉及大量的社工与流程条件。所以虽然 SHA-1 已被攻破,Git 并没有出现大规模的「碰撞导致仓库被污染」的真实案例。这是它还能继续使用的原因,而不是因为它没问题。
为什么迁移这么慢
Git 社区很早就启动了 SHA-256 迁移工作,但进展是渐进的。原因在于对象 ID 渗透在 Git 的每一个角落。
远程协议里会传递对象 ID,打包文件(packfile)里会记录对象 ID 与偏移量,引用(ref)以 ID 命名,.git/objects 的目录结构按 ID 的前两位分片,各种缓存和索引也都依赖 ID 的长度与格式。任何一处假设了「ID 是 40 个字符」,迁移时都要改。
如果只是把算法换掉,所有已存在仓库的历史对象 ID 都会变化,这会导致无法与旧仓库互通。所以迁移方案需要支持两种格式共存,并允许在某个时间点做一次性转换。
这也是一个普遍规律:一个算法如果被嵌进了存储格式和协议,迁移成本会远高于只在接口层使用它的情况。
作为使用者该关心什么
对绝大多数使用 Git 的开发者来说,不需要因为 SHA-1 而改变日常操作。Git 的完整性保护是多层的,对象 ID 只是其中一层。
如果确实需要更强的保证,可以考虑两种做法。一是在发布流程里对产物计算 SHA-256 并签名,把可信锚点放在发布环节,而不是依赖仓库内部的 ID。二是给关键提交做 GPG 签名,用签名来证明提交的来源,这比 ID 本身更能抵抗伪造。
另外,如果项目需要长期保存且对完整性有高要求,可以关注 Git 的 SHA-256 支持进展,在新建仓库时评估是否采用新格式。混合使用 SHA-1 与 SHA-256 仓库时要注意互通限制。
检查清单
- Git 用 SHA-1 计算 blob、tree、commit 的对象 ID,实现内容寻址
- Git 在哈希输入里拼接了对象类型与长度,提高了构造碰撞的难度
- 迁移慢的原因是对象 ID 渗透在协议、存储格式和引用命名中
- 日常使用不需要因为 SHA-1 改变操作方式
- 需要更强保证时,在发布环节对产物计算 SHA-256 并签名
- 关键提交用 GPG 签名,比依赖对象 ID 更能证明来源