Skip to content

Latest commit

 

History

History
290 lines (246 loc) · 18.6 KB

File metadata and controls

290 lines (246 loc) · 18.6 KB

Rails MemCacheStore/RedisCacheStoreraw 캐시 값에 대한 무조건적 Marshal.load (CVE-2020-8165)

메타데이터

항목 내용
CVE ID CVE-2020-8165
영향 버전 rails(activesupport) 5.2.4.3 미만, 6.0.3.1 미만 — MemCacheStore 또는 RedisCacheStore를 사용하는 애플리케이션
CWE CWE-502 (Deserialization of Untrusted Data)
CVSS v3.1 9.8 (Critical, AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
공개일 2020-05-18 (Rails 5.2.4.3 / 6.0.3.1 릴리스와 함께 공개)
수정 커밋 MemCacheStore: 0a4cf42311b35d46718ab3bbfac3d51568bdf23d, RedisCacheStore: 9b4aef4be3dc58eb08f694387857b52be8050954

개요

Rails의 캐시 스토어(ActiveSupport::Cache::Store)는 cache.write(key, value)로 값을 저장할 때, 그 값을 메타데이터(만료 시각, 버전 등)와 함께 Entry라는 래퍼 객체로 감싼 뒤 Marshal.dump로 직렬화해서 memcached/Redis에 바이트 문자열로 저장한다. 그런데 cache.write(key, value, raw: true)처럼 raw: true 옵션을 주면, 이 Entry 래핑과 Marshal 직렬화를 건너뛰고 값을 있는 그대로(plain string)의 바이트열로 저장한다 — 주로 memcached/Redis의 원자적 INCR/DECR 명령과 호환되는 순수 숫자 문자열을 저장할 때 쓰인다. 문제는 읽기(deserialize_entry) 쪽이 이 구분을 전혀 알지 못했다는 데 있다. 취약 버전의 MemCacheStore/RedisCacheStore는 어떻게 쓰였든 상관없이 캐시에서 읽어온 모든 값에 대해 일단 Marshal.load(value)를 시도했다(실패하면 원본 문자열을 그대로 반환하는 방식으로 "안전망"만 뒀다). 그 결과, 애플리케이션이 신뢰할 수 없는 사용자 입력값을 raw: true로 캐시에 써넣기만 하면 — 공식 보안 권고가 제시한 예시는 cache.fetch("demo", raw: true) { untrusted_string } — 그 값을 다시 읽는 순간 Rails가 자발적으로 그 문자열을 역직렬화 대상(Marshal 페이로드)으로 취급해버린다. Ruby의 Marshal.load는 잘 알려진 위험한 역직렬화 함수라서, 공격자가 미리 준비한 바이트열이 이 경로를 타면 임의 코드 실행(RCE)까지 이어질 수 있다.

사전 지식

Rails 캐시 스토어와 Entry 래퍼: 왜 그냥 문자열로 저장하지 않는가

Rails.cache(즉 ActiveSupport::Cache::Store)는 memcached/Redis 같은 외부 키-값 저장소를 앞에 두고 "타입이 있는 캐시"처럼 다루게 해주는 추상화 계층이다. 문제는 memcached/Redis 자체는 값으로 순수 바이트열만 받는다는 점이다 — 정수, 배열, 커스텀 Ruby 객체, 만료 시각, 캐시 버전(무효화용) 같은 부가 정보는 그 자체로는 저장할 방법이 없다. 그래서 Rails는 값을 저장하기 전에 이 모든 정보를 Entry라는 내부 래퍼 객체로 감싼다(activesupport/lib/active_support/cache.rb):

class Entry # :nodoc:
  def initialize(value, compress: true, compress_threshold: DEFAULT_COMPRESS_LIMIT,
                 version: nil, expires_in: nil, **)
    @value      = value
    @version    = version
    @created_at = Time.now.to_f
    @expires_in = expires_in && expires_in.to_f
    compress!(compress_threshold) if compress
  end
  ...
end

Entry 인스턴스는 Marshal.dump(entry)로 직렬화되어 memcached/Redis에 저장되고, 읽을 때는 Marshal.load(bytes)로 원래의 Entry 객체(와 그 안의 @value, @expires_in, @version)를 그대로 복원한다. Marshal은 Ruby 객체 그래프를 이진 형식으로 직렬화/역직렬화하는 표준 라이브러리로, JSON과 달리 임의의 Ruby 클래스 인스턴스를 (그 클래스가 로드돼 있기만 하면) 그대로 복원할 수 있다는 점이 특징이다.

raw: true가 존재하는 이유: 원자적 카운터와의 상호운용

그런데 캐시를 항상 이렇게 "Rails만 이해하는 Entry 포맷"으로만 쓰면 곤란한 경우가 있다. 대표적으로 cache.increment("counter")/cache.decrement("counter")는 memcached/Redis 자체가 제공하는 원자적 증감 명령(memcached의 incr/decr, Redis의 INCR/DECR)에 그대로 위임되는데, 이 명령들은 저장된 값이 순수한 ASCII 숫자 문자열이어야 동작한다. Entry 객체를 통째로 Marshal 직렬화한 바이너리 블롭 안에 숫자가 파묻혀 있다면 이 명령이 그 값을 정수로 인식할 수 없다. 그래서 write(key, value, raw: true)를 쓰면 Entry 래핑과 Marshal.dump를 모두 건너뛰고 value.to_s있는 그대로 저장한다 — 이렇게 하면 memcached/Redis의 네이티브 INCR가 그 값을 곧바로 이해할 수 있다. 즉 raw: true는 "이 값은 Rails의 Entry 포맷이 아니라 백엔드가 직접 이해하는 평범한 문자열이다" 라는 표시다.

Marshal.load가 위험한 이유

Ruby의 Marshal.load는 입력 바이트열이 지정하는 클래스의 인스턴스를 만들면서, 그 클래스에 _loadmarshal_load 같은 커스텀 복원 메서드가 정의돼 있으면 그 메서드를 그대로 호출한다. 즉 역직렬화 과정 자체가 임의의 애플리케이션/라이브러리 코드를 실행시킬 수 있는 진입점이 된다 — Java의 readObject()나 PHP의 unserialize() 가젯 체인과 같은 부류의 위험이다. Ruby 생태계에서도 표준 라이브러리나 널리 쓰이는 gem 클래스들을 조합해 Marshal.load 한 번으로 임의 명령을 실행시키는 가젯 체인이 여러 차례 공개된 바 있다. 이 때문에 Ruby 공식 문서와 Rails 보안 관례 모두 "신뢰할 수 없는 입력에 Marshal.load를 호출하지 말라"는 점을 일관되게 강조한다. 이 CVE의 핵심은 바로 이 원칙이 Rails 캐시 스토어 내부에서 의도치 않게 깨져 있었다는 것이다.

취약점 분석

패치 전 MemCacheStore#deserialize_entry

# activesupport/lib/active_support/cache/mem_cache_store.rb (패치 전)
def deserialize_entry(raw_value)
  if raw_value
    entry = Marshal.load(raw_value) rescue raw_value   # (1) 무조건 먼저 Marshal.load 시도
    entry.is_a?(Entry) ? entry : Entry.new(entry)
  end
end

이 함수는 memcached에서 읽어온 값이 어떤 방식으로 쓰였는지(raw였는지 아닌지)를 전혀 알지 못한 채 호출된다. raw_value가 무엇이든 일단 Marshal.load부터 시도하고, 그 과정에서 예외가 나면(rescue raw_value) 그제서야 원본 값을 그대로 쓰는 식으로 "복구"한다. RedisCacheStore 쪽도 구조가 동일했다.

# activesupport/lib/active_support/cache/redis_cache_store.rb (패치 전)
def deserialize_entry(serialized_entry)
  if serialized_entry
    entry = Marshal.load(serialized_entry) rescue serialized_entry
    entry.is_a?(Entry) ? entry : Entry.new(entry)
  end
end

공격 시나리오: raw: true로 쓴 공격자 제어 문자열

Rails 공식 보안 권고가 제시한 취약 사용 패턴은 다음과 같다.

data = cache.fetch("demo", raw: true) { untrusted_string }

raw: true로 썼기 때문에 쓰기(write) 시점에는 Entry 래핑도, Marshal.dump도 전혀 일어나지 않는다 — untrusted_string이 그대로 memcached/Redis에 저장된다. 만약 untrusted_string이 공격자가 통제할 수 있는 값(예: 사용자 입력을 그대로 캐시 키의 값으로 쓰는 코드, 여러 테넌트가 같은 memcached/Redis 인스턴스를 공유하는 환경에서 다른 테넌트가 자신의 네임스페이스에 쓴 값 등)이라면, 공격자는 자신이 만든 Marshal 페이로드 바이트를 그 문자열 안에 그대로 심을 수 있다.

그런데 읽기(read) 시점에는 이 캐시 값이 raw: true로 쓰였다는 사실을 deserialize_entry가 전혀 전달받지 못한다. cache.read("demo")(혹은 fetch)를 호출하면 위에서 본 deserialize_entry가 그대로 실행되어, 저장된 원본 바이트를 곧바로 Marshal.load에 넘긴다. rescue raw_value라는 "안전망"은 오직 Marshal.load가 예외를 던질 때만 발동하는데, 공격자가 애초에 유효한 Marshal 페이로드를 만들어 넣었다면 예외는 발생하지 않고 Marshal.load는 성공적으로 그 페이로드가 지정하는 객체를 만들어낸다 — 그 과정에서 가젯 체인에 해당하는 클래스의 커스텀 로드 메서드가 실행되며 임의 코드 실행으로 이어질 수 있다.

텍스트로 그린 그림 — 패치 전: raw 여부가 쓰기와 읽기 사이에서 소실됨

   [쓰기 경로]                                    [읽기 경로]

   cache.write("demo", untrusted_string,          cache.read("demo")
                raw: true)                                │
        │                                                 ▼
        ▼  raw:true 이므로 Entry 래핑/Marshal.dump   deserialize_entry(raw_value)
           모두 건너뜀                                     │  raw 였는지 여부를 알 방법이
        │                                                  │  없음 — 무조건 시도
        ▼
   +--------------------------------+              Marshal.load(raw_value)  <- 여기서
   |  memcached/Redis                |  ----읽기---->  공격자가 만든 유효한 Marshal
   |  key="demo"                     |                 페이로드라면 예외 없이 성공
   |  value = untrusted_string       |                       │
   |  (공격자가 완전히 통제하는 바이트) |                       ▼
   +--------------------------------+              지정된 클래스의 인스턴스 생성
                                                     + 커스텀 _load/marshal_load 호출
                                                            │
                                                            ▼
                                                     가젯 체인 실행 → 임의 코드 실행(RCE)

이 그림에서 겹치는(문제가 되는) 지점은 "쓰기 시점의 raw: true라는 맥락 정보가, 읽기 시점의 deserialize_entry 호출에는 전달되지 않는다"는 화살표 구간이다 — 함수 시그니처 자체(deserialize_entry(raw_value))에 raw 여부를 나타내는 인자가 아예 없었다.

수정 방법

두 스토어의 수정 방식은 서로 다르다 — memcached 클라이언트(Dalli)는 애초에 값이 직렬화됐는지를 자체적으로 기록해 두므로 그 정보를 재사용했고, Redis 클라이언트는 그런 기능이 없어 Rails가 직접 raw 여부를 읽기 호출까지 명시적으로 전달하도록 바꿨다.

MemCacheStore: Dalli의 marshalled 플래그에 위임

--- a/activesupport/lib/active_support/cache/mem_cache_store.rb
+++ b/activesupport/lib/active_support/cache/mem_cache_store.rb
@@
-require "active_support/core_ext/marshal"
 require "active_support/core_ext/array/extract_options"
@@
-        def deserialize_entry(raw_value)
-          if raw_value
-            entry = Marshal.load(raw_value) rescue raw_value
+        def deserialize_entry(entry)
+          if entry
             entry.is_a?(Entry) ? entry : Entry.new(entry)
           end
         end

memcached 프로토콜 자체에 값마다 붙는 "flags"라는 32비트 필드가 있고, Dalli(Rails가 memcached와 통신할 때 쓰는 클라이언트 gem)는 값을 Marshal로 직렬화해 저장할 때 이 flags에 "이 값은 마샬링됐다"는 비트를 함께 기록해 둔다. 패치 후 deserialize_entry는 더 이상 스스로 Marshal.load를 호출하지 않고, Dalli가 값을 가져올 때 이미 그 flags를 보고 마샬링된 값이면 알아서 언마샬링하고, 마샬링되지 않은(= raw: true로 쓰인) 값이면 그대로 돌려준 결과를 받는다. 즉 "이 바이트가 Marshal 페이로드인지"를 값 자체의 내용을 보고 사후에 추측(rescue로 실패 여부 판정)하는 대신, 쓰기 시점에 신뢰할 수 있는 채널 (memcached의 flags 필드)로 미리 기록해 둔 사실을 그대로 신뢰하는 방식으로 바뀐 것이다.

RedisCacheStore: 호출자가 raw: 여부를 명시적으로 전달

Redis는 memcached의 flags 같은 부가 메타데이터 필드가 없으므로, 대신 deserialize_entry 자체의 시그니처를 바꿔 호출자가 "이 값이 raw로 쓰인 것인지"를 명시적으로 알려주도록 했다.

--- a/activesupport/lib/active_support/cache/redis_cache_store.rb
+++ b/activesupport/lib/active_support/cache/redis_cache_store.rb
@@
         def read_entry(key, **options)
           failsafe :read_entry do
-            deserialize_entry redis.with { |c| c.get(key) }
+            raw = options&.fetch(:raw, false)
+            deserialize_entry(redis.with { |c| c.get(key) }, raw: raw)
           end
         end
@@
-        def deserialize_entry(serialized_entry)
+        def deserialize_entry(serialized_entry, raw:)
           if serialized_entry
-            entry = Marshal.load(serialized_entry) rescue serialized_entry
-            entry.is_a?(Entry) ? entry : Entry.new(entry)
+            if raw
+              Entry.new(serialized_entry)
+            else
+              Marshal.load(serialized_entry)
+            end
           end
         end

raw: 키워드 인자가 필수(default 없음)로 추가되어, 이 함수를 호출하는 모든 지점이 반드시 자신이 지금 읽으려는 값이 raw로 쓰였는지 아닌지를 명시해야 한다. raw가 참이면 Marshal.load를 아예 호출하지 않고 값을 곧바로 Entry.new()로만 감싸며, 거짓일 때만 Marshal.load를 호출한다 — 더 이상 "일단 시도하고 실패하면 원본으로 되돌아가는" rescue 기반의 추측이 아니라, 호출자가 쓰기 시점에 알고 있던 사실을 읽기 시점까지 그대로 이어받는 방식이다. cache.increment/decrement처럼 내부적으로 raw 값을 읽는 다른 호출부들도 이 커밋에서 함께 raw: true를 명시하도록 수정됐다.

텍스트로 그린 그림 — 패치 후: raw 여부가 쓰기부터 읽기까지 이어짐

   [MemCacheStore]                                  [RedisCacheStore]

   write(raw:true) → Dalli가 flags에                 write(raw:true) → 그대로 저장
   "마샬링 안 됨" 기록                                 (메타데이터 없음)
        │                                                  │
        ▼                                                  ▼
   read() → Dalli가 flags를 보고                      read(raw:true 명시) → deserialize_entry(v, raw:true)
   자체적으로 마샬링 여부 판단 후 반환                        │
        │                                                  ▼
        ▼                                       if raw: Entry.new(v)  <- Marshal.load 호출 자체가 없음
   deserialize_entry(entry)                           else: Marshal.load(v)
   Marshal.load를 애초에 호출하지 않음
        │                                                  │
        ▼                                                  ▼
   공격자가 raw 값에 무엇을 넣든           공격자가 raw 값에 무엇을 넣든 Marshal.load
   Marshal.load 경로를 절대 타지 않음        경로를 절대 타지 않음(raw:true가 명시된 한)

두 수정 모두 공통된 방향을 가진다 — "역직렬화 여부를 값의 내용으로부터 사후에 추측"하던 것을, "쓰기 시점에 이미 알고 있던 사실(raw였는지 아닌지)을 신뢰할 수 있는 경로로 읽기 시점까지 전달"하는 것으로 바꿨다. 이 수정은 Rails 5.2.4.3과 6.0.3.1에 포함됐다.

참고 자료

미해결/불확실 지점

  • HackerOne 최초 보고서(hackerone.com/reports/413388)는 비공개 처리되어 있어 원 보고자가 제시한 구체적 익스플로잇 시나리오(어떤 애플리케이션 코드 패턴에서 공격자가 실제로 raw: true 캐시 값을 완전히 통제할 수 있었는지, 실제 사용한 Marshal 가젯 체인)는 확인하지 못했다. 이 리포트는 공식 보안 권고가 제시한 최소 재현 코드 (cache.fetch("demo", raw: true) { untrusted_string })와 패치 diff만으로 근본 원인을 분석했다.
  • weblog.rubyonrails.org의 공식 릴리스 발표문은 이 세션의 네트워크 환경에서 DNS 조회 자체가 실패해 직접 열람하지 못했다. NVD가 해당 URL을 1차 참고 자료로 명시하고 있고 내용이 rubyonrails-security 메일링 리스트 공지와 동일할 것으로 판단해 인용은 유지했으나, 직접 대조 검증은 하지 못했다.