“原子耗尽不是踩地雷,它贡献了我们三分之一的CVE。”Erlang生态系统基金会的CNA在公布漏洞统计时用了这个标题。35.8%的已发布CVE归入无控制资源消耗类别,BEAM生态里,这背后频繁出现的就是同一个机制——原子耗尽。打开当前EEF CNA的弱点分布页面,这个数字就挂在最显眼的位置。

原子耗尽是一种拒绝服务漏洞。Erlang/Elixir虚拟机里的原子就像不可变的标签,比如:ok、:error,它们存放在一个全局原子表里。这个表的大小有限,默认是1,048,576个槽位,而且原子不会被垃圾回收——一旦创建就永远驻留。当代码放任外部输入来动态生成原子,最终表会满,整个虚拟机直接崩溃。更棘手的是,这不需要攻击者突破任何权限,只要能够提交输入,就能远程掐断服务,这是一个潜伏的DoS。

打开网易新闻 查看精彩图片

最显眼的诱因当然是二进制转原子、字符串转原子的那些函数:binary_to_atom/1、list_to_atom/1,Elixir里的String.to_atom/1、List.to_atom/1。但真正的陷阱往往不那么直白。你看这段Erlang代码:list_to_atom("field_" ++ UserInput),字符串拼接里夹着用户输入,每个不同的UserInput都会生成一个新原子。Elixir里那个熟悉的JSON解析:Jason.decode(json, keys: :atoms),如果接收到一个来自外部的、键名千变万化的JSON,每个新键就是一颗埋进原子表的地雷。还有插值写法::"field_#{user_input}",同样会在编译时或运行时将用户数据埋成一个新原子。开发者的本意可能只是处理有限的几个固定字段,可一旦输入源不受控,原子表就成了一块柔软的腹地。

为什么这类漏洞会反复出现?并不是因为大家粗心。多数代码里,输入在编写时被认定为可控或有限。URI方案就是个典型。开发者想:总共就http、https、ftp那么几种,从外部拿到的scheme字段理论上不会超出这个范围。于是顺手list_to_atom(Scheme)。可如果这个值最终来自HTTP请求头、URL参数或者任何不受信来源,攻击者可以投喂任意字符串,有限集合的假设瞬间崩塌。类似的场景随处在:请求头名、配置键、消息队列的主题、图形QL的字段……只要用户能够影响标签名字,原子耗尽的风险就握在对方手里。关键教训就是:用输入创建原子,必须真正确保可能值的集合是有限的、已知的,而且有强制校验,否则就别走这条路。

防守的第一原则其实很简单:运行时别再动态创建新原子。当你知道可接受的值就那么几个,最安全的方式是手写查找表,不用什么奇技淫巧。Erlang里可以这样匹配:

case Scheme of    <<"http">> -> http;    <<"https">> -> https;    _ -> errorend

如果合法列表较长或者需要更高频次访问,一个映射表替代案同样有效,关键是绝不把动态输入灌注成原子。这种模式把攻击面彻底冻结——无论外部输入怎样变化,代码路径里根本就不会产生新的原子。

打开网易新闻 查看精彩图片

当查找表不太适用时,第二个选择是安全函数变体,它们只允许使用已经存在的原子,遇到陌生值会直接报错,而不是创造出一个新的。Erlang提供binary_to_existing_atom/1、list_to_existing_atom/1,Elixir对应String.to_existing_atom/1、List.to_existing_atom/1。一旦调用,如果系统里还没有这个原子,函数就会抛出参数错误,提醒你输入越界了。这样一来,攻击者就算传进十万个怪异字符串,也只能得到一堆错误日志,而不是一次虚拟机崩溃。

代码检查工具能帮你在漏洞形成前拦住危险写法。Elixir项目可以考虑启用Credo的Credo.Check.Warning.UnsafeToAtom规则。这个规则默认关闭,但只要在.credo.exs里打开,它就会标记所有可能不安全的String.to_atom/1、List.to_atom/1、Module.concat/1,2以及Jason.decode/2带了keys: :atoms的写法。让CI流水线替你按住风险,是最省心的防护。对于Erlang项目,可以使用dialyzer搭配自定义规则,或者用安全扫描工具检索binary_to_atom、list_to_atom的调用,排查参数是否来自外部输入。

如果你维护着Erlang或Elixir项目,现在就是翻看代码的最好时机。搜索目标应该包括:任何从二进制、字符串转原子的地方;JSON键名被转为原子的位置;URI组件、HTTP头、消息中间件主题以及各种配置值被直接原子化的操作。这个动作成本很低,却是防御价值极高的安全卫生习惯。比起等到CVE编号被分配出来再去修补,主动消灭这些潜伏点轻松得多。EEF安全工作组也提供了详细的预防指南,涵盖从编码规范到架构设计的完整建议。

原子耗尽不是一个复杂到难以理解的概念,但它一再成为CVE榜单上的常客。只要把“外部输入不生成新原子”这条原则嵌进团队的编码直觉里,三分之一的安全公告可能就直接消失了。下次当你想要把用户的参数变成:some_key,停下来问自己一句:这个值真的有可能永远只有这么几个吗?如果答案不是绝对肯定,换一条安全路线——用字符串、用映射、用已存在原子检查——永远来得及。