4月29日星期三,Thread开始经历我们的主要后端服务的部分中断。我们将问题追溯到存在格式错误的memcached密钥,并在thread.com上更正了该问题。一直以来,我们怀疑这可能会在一些使用memcached的Djangosite上被利用,以导致私人数据泄露-无论是内部服务数据还是关于其他用户的数据。Thread上唯一的问题是HTTP 500-少数用户看到的服务器错误,没有私人数据被泄露。
我们在同一天通过他们最喜欢的披露程序向Django安全团队报告了这一点,提供了一份完整的关于可能的修复的报告。
经过一些讨论后得出的结论是,这个问题确实代表了基于姜戈的网站的安全漏洞,并被分配了新的标识符CVE-2020-13254。该修复程序由安全团队审核并合并,并于6月3日在3.0.7和2.2.13版本中发布。
之前相关的Django在Django讨论了这个问题的历史,讨论了为什么它可能没有更早地实现。
为什么不早点发现这个呢?讨论为什么容易犯这些错误。
这个错误是我一段时间以来调查过的最困难的错误之一,有很多死胡同。我们看到了一些症状,表明许多类型的缓存查询都失败了,但最明显的例子之一是:
#in`django/core/cache/backends/memcached.py`def get_any(self,key,version=NONE):key_map={self。make_key(key,version=version):key in key的key}ret=self._cache。GET_MULTI(KEY_MAP.KEYS())#`KEYError`For Key`b';:1:alternate-colours:1:15492594:213';`返回{KEY_MAP[k]:v for k,v in ret。Items()}#其中:KEY_MAP={b';:1:preferred-sizes-v2:88625:15492594';:';首选大小-v2:88625:15492594';}v=[15492576,15492582,15492619,15492641]。
Django在其缓存后端系统上提供了GET_MANY函数。这需要可迭代键,并返回从这些键到来自缓存的值的映射,使用缓存的批量查询功能(如果没有)。
这里的问题是,虽然输入到函数的键是首选大小的键(对于适合年轻用户的产品的大小),但缓存在ret中返回了一个替代颜色值,缓存后端无法匹配它正在查询的键,从而引发了KeyError。
这把我们难倒了。看起来缓存返回了错误的数据,但memcached是一块坚如磐石的基础设施,经过战斗考验的公司比我们大得多,所以漏洞更有可能存在于我们的代码中。
第一站停靠的是我们运来的新变化。我们每天向生产发货多达40次,所以这通常是一个很难回答的问题,但可疑的提交在我们如何使用一些核心缓存数据以及如何将其序列化方面发生了很大变化。我们检查以确保给定键的缓存中的数据是有效的,因此我们进入了一个兔子洞,调查序列化行为、酸洗(Python形式的序列化)和许多其他问题。事实证明,这是一条死胡同。
由于数据是有效的,而我们的查询和序列化似乎不正确,这表明我们和memcached之间存在问题。几个小时后,我们怀疑问题可能是重新使用了文件指针。如果这两个进程可以访问同一个文件指针,并且都在写入查询并尝试读取结果,它们就可以读取对方的结果。我们花了一些时间调查这是如何发生的,但让我们相信这不是问题的是,来自memcached的错误响应不是畸形的,它们不会在键或值的中间被截断,这一行为我们几乎肯定会看到。
最终,一位一直在代码的另一个区域工作的同事问我们,这是否可能是相关的,他推动了一些没有在生产中起作用的更改。他发现,通过几层抽象,他一直在编辑的一个重要价值-一个人类可读的标题-最终出现在一个新的缓存键中。他用第一个多字标题更新了一些代码,因此无意中在缓存键中引入了一个空格字符,这是Memcached没有遵循的。
通过在缓存键中包含空格,我们的连接与memcached响应的数据不同步。这一点最好的例证是TwoRonnies的MasterMind小品。
在阅读了Django、PyLibMC和C源代码Of libmemcached的源代码一天后,排除了不经意间升级包或进程共享文件描述符等多种可能性,发现这个bug“只是”缓存键中的一个空格,这让人有点失望。然而,它并没有说明这在其他代码库中的可能性有多大,甚至可能有多大,也没有说明这可能有多危险。
该网站必须使用Django、memcached和PyLibMC或其他不验证密钥的Memcached驱动程序(请注意,python-memcached不验证密钥,不会被认为是不可利用的)。
用户对最终将在缓存键中未处理的内容的控制。这可以是一个字符串,但同样也可以是一个与表单控件相关联的值。
网站使用缓存的方式必须是,在攻击者可以控制的缓存键之后查询引用敏感数据的缓存键-尽管这不是针对每个请求,而是在安全服务器进程的整个生命周期内。
示例代码库通过两种方式演示了利用该漏洞的方法,即通过一个Simpleweb界面和通过一个失败的测试用例。
该示例提供了一个具有2个窗体的Web界面,一个窗体在缓存中设置值,另一个窗体获取值。这些被直接转换为对Django缓存后端的调用。因为代码库没有实现任何会话或身份验证系统,所以同一浏览器选项卡中的多个使用与多个用户在不同机器之间使用没有什么区别。
从django.core.cache导入缓存从django.test导入TestCase类CacheTests(TestCase):def test_cache(Self):cache。设置(';K1';,';v1';)缓存。设置(';K2';,';v2';)TRY:CACHE。设置(';a b';,';v3';),例外情况除外:传递自我。assertEquity([';K2';,';K1';,';K2';,';v2';]],[';v2';,';v1';,';v2';;,';v1';,';v2';,';v1';],)。
==失败:TEST_CACHE(demo.tests.CacheTests)--回溯(最近一次调用):文件";tests.py";,第30行,位于TEST_CACHE';v1';,AssertionError:列表与第一个不同元素0:None';V2';-[无,';v2';,';v2';,';v1';,';v1';,';v2';]?-+[';v2';,';v1';,';v1';,';v1';,#。,';v1';]?+-。
正如您所看到的,在创建集合之后,返回的缓存结果与正在进行的查询不同步。
在调查期间,我们发现Django已经验证了缓存键,以确保它们不包含空格,以及验证它们不包括大量其他无效字符,并且在最大键长度之内。不幸的是,这种缓存验证只在非memcached的后端发生,这不是故意的!
从历史上看,似乎在一些地方追求速度,在另一些地方追求开发经验,每一个地方的应用都不平衡,我们最终落到了这个奇怪的位置,在这个位置上,不需要它的后端有它,而那些不需要它的后端没有它。
2008年1月,在Django的bug跟踪器上打开了问题#6447。它实质上表明,因为memcached有这些限制,所以用于本地开发的缓存后端(它们只将缓存存储在进程中,不适合生产)也应该做同样的验证,以便在本地使用开发后端但在生产中使用memcached的开发人员,一旦他们将代码交付给生产环境,就不会被缓存键有效性问题所困扰。
在同一张票证上,决定将警告(但不是错误)添加到Tonon-memcached后端以提供帮助,但不会将它们添加到Tonon-memcached后端本身,因为:
虽然这种对性能的奉献值得称赞,但这里的关键验证是对字符串的简单字符串检查,无论如何必须是255个字符或更短(memcached键限制)。这不仅可能是非常快速的访问操作,而且还会在导致网络往返的缓存查询期间发生。
2013年2月,#19914有报道称,在使用PyLibMC和memcached缓存后端时,Django的测试套件失败。在调查过程中发现,在缓存中包含Key…的空格。
这张罚单的结论是将有问题的测试从用于PyLibMC的缓存后端测试套件中删除。
纵观这些门票,姜戈是否应该验证钥匙的问题几次浮出水面,但为什么呢?正如那些电影票的评论者所说的那样,不这样做不是更快吗?也许这不是姜戈的责任来验证这些密钥。
从每个程序员都应该知道的三个著名数字(从2009年开始,它代表了当时正在进行的工作)来看,一个主内存参考大约是100 ns,而同一数据中心内的一个往返网络请求是500,000 ns。字符串验证可能需要几次内存访问,所以我们可以将其称为1,000 ns 1,但即使那样,我们仍然需要大约0.2%的缓存查询开销。
从这个角度来看,它可能没有那么有影响力,但另一个角度是我们正在进行的抽象级别是什么。Django是一个相对较高级别的Web框架-它的目标是为Web开发人员需要做的大多数事情提供易于使用和安全的工具。它的目标不是成为目前性能最高的框架,这样的框架也很可能不是基于Python的。Django和Python已经在速度和开发人员的生产力和安全性方面进行了权衡,避免了段错误或使SQL注入攻击的风险大大降低,从而招致了性能开销。
值得一提的是,默认情况下libmemcached也不会验证键。这可能更合适,因为libmemcached不是被设计成一个使用缓存的安全工具,它被设计成一个快速的Memcached接口,将所有可能的控制权交给开发人员。这里缺少验证对于libmemcached提供的抽象级别来说是合适的。
在Django的目标和Python的价值观的背景下,跳过验证来节省这次时间很可能是错误的设计选择,而缺乏任何影响意味着它可能是错误的技术选择,但很容易陷入以性能为中心的代码视图,忘记开发人员的体验。
2013年的罚单如此接近意识到潜在的安全问题,找到了我们在Thread观察到的确切行为,但错过了它可能对不受信任的在线用户使用的生产系统产生的潜在影响。
拥有以安全为中心的心态是很难的,这是我尽可能多地实践的事情,但作为开发人员,专注于软件应该做什么比不应该做什么要容易得多。我不能责怪Django团队没有发现这一点,我们在Thread加入这些点的原因是,我们可能会在错误监控中查看包含用户ID的缓存键和值,如果没有这一点,我们很可能还没有意识到实际影响。
尽管在过去的10年里,许多人都在关注这个特定的问题,但没有人(公开)将其作为安全漏洞提出。即使在Thread,也只有在我们三个人解决了我们正在调查的漏洞,并大声质疑这是否可能是我们的安全漏洞之后,我们才最终联系起来,并意识到这是一个可能会影响其他网站的问题,可能应该在Django修复。
我写下了这个问题的完整描述,并在Django第一次尝试解决这个问题,并将其发送给安全团队。谢天谢地,Django发布了其安全团队的联系方式,并在他们的漏洞跟踪器中明确提到了这些细节,鼓励开发人员不要提交可能会产生安全影响的公共漏洞。对于在数百万个网站背后运行的Web框架来说,这是一种很好的实践。
我收到了回复,确认他们在几个小时内就收到了报告。几天后,该团队对这封电子邮件进行了简短的讨论,提出了问题,并指着之前讨论过这一问题的门票,尽管没有从安全角度出发。
经过一番反复,5月6日确认这确实是一个可利用的安全漏洞,应该在Django修复。
我完成了我的补丁,包括测试和文档修复,并于5月8日提交。这一点得到了审查小组的审查和接受。
Django安全团队计划于6月1日在3.1a2、3.0.7和2.2.13发布补丁。
感谢Django安全团队,整个过程非常容易。当有人告诉你你的产品中存在安全漏洞时,很容易防守,但他们没有自我意识地来到这个过程中。我已经发现Djangos社区是乐于助人、友好和专业的,这个过程进一步巩固了这种感觉。
我将从这次经历中学到的一件事是,当某件事是一个安全问题时,它并不总是那么明显。这是一个细微的平衡,包括代码在生产中的使用方式,可能是抽象级别之外的攻击向量,开发人员认为他们应该做什么,以及从性能的角度来看是否合适。
再次感谢Django安全团队,也感谢我的同事Alistair Lynn和Aaron KirkBride,他们都帮助调试了这个问题,并最终认识到该漏洞的更广泛影响。
这当然是有争议的,但考虑到一个有效的键最多为2255个字符,我们讨论的最大长度可能是250-500个字节,假设大多数缓存键是ASCII或可以像大多数书面语言一样用2字节的Funicode数据表达的公共扩展。要分析的500个字节的字符串可能会在10次以下的操作中加载到CPU高速缓存中。
↩