| 항목 | 내용 |
|---|---|
| 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.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 클래스 인스턴스를
(그 클래스가 로드돼 있기만 하면) 그대로 복원할 수 있다는 점이 특징이다.
그런데 캐시를 항상 이렇게 "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 포맷이 아니라 백엔드가 직접 이해하는 평범한 문자열이다"
라는 표시다.
Ruby의 Marshal.load는 입력 바이트열이 지정하는 클래스의 인스턴스를 만들면서, 그
클래스에 _load나 marshal_load 같은 커스텀 복원 메서드가 정의돼 있으면 그 메서드를
그대로 호출한다. 즉 역직렬화 과정 자체가 임의의 애플리케이션/라이브러리 코드를 실행시킬
수 있는 진입점이 된다 — Java의 readObject()나 PHP의 unserialize() 가젯 체인과 같은
부류의 위험이다. Ruby 생태계에서도 표준 라이브러리나 널리 쓰이는 gem 클래스들을 조합해
Marshal.load 한 번으로 임의 명령을 실행시키는 가젯 체인이 여러 차례 공개된 바 있다.
이 때문에 Ruby 공식 문서와 Rails 보안 관례 모두 "신뢰할 수 없는 입력에 Marshal.load를
호출하지 말라"는 점을 일관되게 강조한다. 이 CVE의 핵심은 바로 이 원칙이 Rails 캐시
스토어 내부에서 의도치 않게 깨져 있었다는 것이다.
# 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
endRails 공식 보안 권고가 제시한 취약 사용 패턴은 다음과 같다.
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는 성공적으로 그 페이로드가 지정하는 객체를 만들어낸다 — 그 과정에서
가젯 체인에 해당하는 클래스의 커스텀 로드 메서드가 실행되며 임의 코드 실행으로 이어질 수
있다.
[쓰기 경로] [읽기 경로]
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 여부를 읽기 호출까지 명시적으로 전달하도록 바꿨다.
--- 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
endmemcached 프로토콜 자체에 값마다 붙는 "flags"라는 32비트 필드가 있고, Dalli(Rails가
memcached와 통신할 때 쓰는 클라이언트 gem)는 값을 Marshal로 직렬화해 저장할 때 이
flags에 "이 값은 마샬링됐다"는 비트를 함께 기록해 둔다. 패치 후 deserialize_entry는
더 이상 스스로 Marshal.load를 호출하지 않고, Dalli가 값을 가져올 때 이미 그 flags를
보고 마샬링된 값이면 알아서 언마샬링하고, 마샬링되지 않은(= raw: true로 쓰인) 값이면
그대로 돌려준 결과를 받는다. 즉 "이 바이트가 Marshal 페이로드인지"를 값 자체의 내용을
보고 사후에 추측(rescue로 실패 여부 판정)하는 대신, 쓰기 시점에 신뢰할 수 있는 채널
(memcached의 flags 필드)로 미리 기록해 둔 사실을 그대로 신뢰하는 방식으로 바뀐 것이다.
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
endraw: 키워드 인자가 필수(default 없음)로 추가되어, 이 함수를 호출하는 모든 지점이
반드시 자신이 지금 읽으려는 값이 raw로 쓰였는지 아닌지를 명시해야 한다. raw가 참이면
Marshal.load를 아예 호출하지 않고 값을 곧바로 Entry.new()로만 감싸며, 거짓일 때만
Marshal.load를 호출한다 — 더 이상 "일단 시도하고 실패하면 원본으로 되돌아가는"
rescue 기반의 추측이 아니라, 호출자가 쓰기 시점에 알고 있던 사실을 읽기 시점까지
그대로 이어받는 방식이다. cache.increment/decrement처럼 내부적으로 raw 값을 읽는
다른 호출부들도 이 커밋에서 함께 raw: true를 명시하도록 수정됐다.
[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에 포함됐다.
- NVD CVE-2020-8165 — 공식 CVE 설명, CVSS 9.8. (검증됨)
- Rails 공식 보안 권고 — rubyonrails-security 메일링 리스트 — 취약점 설명, 영향 버전, 수정 버전, 예시 취약 코드(
cache.fetch("demo", raw: true) { untrusted_string }). (검증됨, 1차 자료) - GitHub Advisory GHSA-2p68-f74v-9wc6 — 공식 보안 권고와 동일 내용의 구조화된 버전. (검증됨, 1차 자료)
- 수정 커밋 0a4cf42 — MemCacheStore, rails/rails — 실제 패치 diff 직접 확인(
deserialize_entry시그니처 변경, Dalli 위임). (검증됨, 1차 자료) - 수정 커밋 9b4aef4 — RedisCacheStore, rails/rails — 실제 패치 diff 직접 확인(
raw:키워드 인자 추가). (검증됨, 1차 자료) activesupport/lib/active_support/cache.rb(패치 시점 소스,Entry클래스) —Entry래퍼 구조 확인. (검증됨, 1차 자료)- rubyonrails.org — Rails 5.2.4.3 and 6.0.3.1 have been released — 공식 릴리스 발표(NVD 참고 자료 목록에 포함). (자동화 세션에서 접속 실패로 직접 확인 못함 — NVD가 1차 자료로 명시한 링크만 인용)
- Debian Security Advisory DSA-4766 — 배포판 대응 확인. (검증됨)
- 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 메일링 리스트 공지와 동일할 것으로 판단해 인용은 유지했으나, 직접 대조 검증은 하지 못했다.