FlawOpen

flawopen.com/Incidents/rubygems-cdn-cache-api-key-leak-july-2026

● RubyGems.org / Ruby Central · CWE-200 / CWE-524 · July 2026 · CVSS 7.3 · 높음
보안 연구 · FlawOpen

RubyGems: CDN 캐시로 인한 API 키 노출

gzip/CDN 캐시 결함으로 RubyGems의 레거시 키가 노출됐습니다. 수정과 복구 방법을 설명하며 별도 캠페인의 AI 연루는 논쟁 중입니다.

💡 알기 쉬운 설명 (ELI5)

신원을 확인한 고객마다 새 마스터 키를 만들어 주는 창구를 떠올려 보세요. 포장 장비가 봉투 라벨을 바꾸면서 사본을 공동 트레이에 놓았습니다. 같은 창구를 다음에 이용한 사람은 신분증을 보이지 않고도 앞선 고객의 키를 가져갈 수 있습니다. 수정 사항은 공유 캐시에 비공개 응답을 저장하지 못하게 하고, 권한 정보에 따라 응답을 분리합니다.

핵심 개념 및 용어

Legacy API key
광범위한 gem 관리 권한을 가진 장기 유효 RubyGems 자격 증명입니다. 보안 공지에 따르면 새 gem 버전 게시, 버전 철회, 소유자 추가, webhook 또는 trusted publisher 설정 변경이 가능했습니다. 계정 로그인이나 이미 게시된 릴리스 수정은 불가능했습니다.
Shared cache / POP
CDN의 POP(Point of Presence)는 이후 요청에서 재사용할 응답을 저장합니다. 자격 증명이 든 인증 응답에 비공개 캐시 정책과 권한별 캐시 키가 없으면 다른 호출자가 그 응답을 받을 수 있습니다.
Compression middleware
RubyGems는 Accept-Encoding: gzip이 Rack 미들웨어 간 상호작용을 일으켜 응답에 private 지시어와 Vary: Authorization이 빠졌다고 설명합니다. gzip이 없는 일반 요청에서는 같은 헤더 동작이 나타나지 않았습니다.
CWE-200 and CWE-524
GitHub Security Advisory는 민감 정보 노출에 CWE-200, 민감 정보를 담은 캐시 사용에 CWE-524를 지정했습니다. 정보 공개와 캐시 경계를 각각 설명하며 CVE는 지정되지 않았습니다.
RubyGems package-flood attribution
RubyGems는 2026년 5월 악성 패키지 게시를 확인했지만 AI 에이전트가 패키지를 만들거나 게시했는지는 판단할 수 없다고 했습니다. 이 캠페인을 캐시 결함과 분리하세요. 캠페인이 키 노출을 일으켰다는 근거는 없습니다.

프레임워크 매핑

  • 이 가이드에서 설명하는 약점의 CWE 참조: CWE-200 CWE-524

사고 타임라인

2020-12-10

RubyGems 3.2.0에서 표준 gem signin 흐름이 범위가 제한된 키 생성 방식으로 바뀌었지만 서버는 기존 GET 경로를 유지했습니다.

2026-07-06

Truffle Security가 캐시 문제를 RubyGems에 보고했습니다.

2026-07-09

RubyGems가 응답 헤더를 수정하고 Fastly의 영향받은 캐시 객체를 삭제했습니다.

2026-07-22

RubyGems가 GHSA-9j48-x3c3-mrp2를 게시하고 모든 레거시 키를 폐기했다고 밝혔습니다. GHSA는 7.3 High, RubyGems는 환경 점수를 포함해 7.2 High로 평가했습니다.

2026-09-11

RubyGems는 5월 패키지 게시 캠페인을 별도로 설명하며 AI 에이전트의 소행으로 귀속할 수 없다고 했습니다.

단계별 공격 실행 흐름

단계 1

기존 경로가 자격 증명을 발급

/api/v1/api_key에 인증된 GET 요청을 보내면 광범위한 권한의 레거시 API 키를 만들어 반환합니다. RubyGems에 따르면 3.2.0 이전 gem 클라이언트가 이 경로를 사용했습니다. 새 클라이언트가 범위 제한 키로 바뀐 뒤에도 서버는 기존 엔드포인트를 유지했습니다.

단계 2

gzip이 헤더 처리 경로를 바꿈

클라이언트가 gzip을 요청합니다. RubyGems는 Rack::Deflater와 Rack::ETag의 상호작용을 캐시 동작의 원인으로 설명합니다. 압축 응답에는 단순한 Cache-Control: no-cache만 남았고 private와 Vary: Authorization이 없었습니다.

단계 3

CDN이 인증된 응답을 재사용

영향받은 Fastly 구성에서는 응답이 POP에 최대 한 시간 저장될 수 있었습니다. 캐시 키가 Authorization별 호출자를 구분하지 않아, edge가 RubyGems로 돌아가 다음 사용자를 인증하지 않고 이전 응답을 반환할 수 있었습니다.

단계 4

다른 호출자가 키를 받음

같은 POP을 지나 동일 캐시 변형을 요청한 호출자는 이전 사용자의 키를 받을 수 있었습니다. RubyGems는 로그인 간 우발적으로 발생할 수 있다고 설명했고, 캐시 응답을 비인증으로 조회하는 가능성도 언급했습니다. 확인 가능한 로그에서 악용 증거는 없었지만 기록 기간은 제한적이었습니다.

단계 5

키 권한이 영향 범위를 제한

유출된 레거시 키로 새 gem 버전 게시, 버전 철회, 소유자 변경, webhook 또는 trusted publisher 설정 변경이 가능했습니다. 기존에 게시된 릴리스는 바꿀 수 없었습니다. RubyGems에 따르면 UI와 API 모두에 MFA를 요구하면 키만으로 하는 게시·철회·소유자 변경을 막습니다.

단계 6

수정이 공유 재사용을 막고 노출된 키를 폐기

패치는 Cache-Control: private, no-store, Surrogate-Control: max-age=0, Vary: Authorization을 추가합니다. RubyGems는 Fastly 객체를 삭제하고 레거시 키를 폐기했으며 GET 로그인 경로를 종료했습니다. 범위 제한 키, API MFA, CI의 trusted publishing을 권장합니다.

소스 코드 비교: 취약한 구현 vs 보안 패치

패치 전 컨트롤러 발췌는 RubyGems 공개 수정에서 삭제된 문맥을 바탕으로 재구성했습니다. 안전하지 않은 캐시 헤더는 해당 action이 공개 캐시 정책을 설정해서가 아니라 Rack 미들웨어의 상호작용으로 만들어졌습니다. 강화된 callback과 헤더 값은 commit d3d11c0과 일치합니다. private, no-store는 공유 저장을 막고, Surrogate-Control은 프록시 캐시를 끄며, Vary: Authorization은 저장되는 경우에도 인증별 응답을 분리합니다. 이는 소스 분석이지 요청 실행 절차나 단독 패치가 아닙니다. 공급업체의 전체 수정 사항을 적용하고 기존 캐시 항목을 삭제하세요.

취약한 구현
class Api::V1::ApiKeysController < Api::BaseController
  include ApiKeyable

  # 수정 전 컨트롤러에는 deny_shared_cache 콜백이 없었습니다.
  def show
    authenticate_or_request_with_http_basic do |username, password|
      user = User.authenticate(username.strip, password)
      check_mfa(user) do
        key = generate_unique_rubygems_key
        api_key = user.api_keys.build(
          legacy_key_defaults.merge(hashed_key: hashed_key(key))
        )
        save_and_respond(api_key, key)
      end
    end
  end
end
보안 강화 패치
class Api::V1::ApiKeysController < Api::BaseController
  include ApiKeyable

  before_action :deny_shared_cache

  def show
    authenticate_or_request_with_http_basic do |username, password|
      user = User.authenticate(username.strip, password)
      check_mfa(user) do
        key = generate_unique_rubygems_key
        api_key = user.api_keys.build(
          legacy_key_defaults.merge(hashed_key: hashed_key(key))
        )
        save_and_respond(api_key, key)
      end
    end
  end
end

# 수정 사항에서 Api::BaseController에 추가됐습니다.
def deny_shared_cache
  response.headers["Cache-Control"] = "private, no-store"
  response.headers["Surrogate-Control"] = "max-age=0"
  response.headers["Vary"] = [response.headers["Vary"], "Authorization"].compact_blank.join(", ")
end

엔지니어링 및 시스템 보안 강화 체크리스트

출처