CVE-2026-110264 (GitLab Org-mode 정수 오버플로 RCE) 분석

CVE-2026-110264 (GitLab Org-mode 정수 오버플로 RCE) 분석

CVE · 웹·서버 · RCE · CVE-2026-110264 · [심층]

덱. 공개 저장소에 올린 .org 문서 파일 하나로, 로그인 없이 GitLab 서버에서 코드를 실행할 수 있었습니다. 원인은 멀리 있지 않았습니다 — 사용자가 적어 넣은 한 “단어”가 그대로 정규식(regular expression)이 되어, 정규식 엔진 안의 16비트짜리 작은 칸을 넘겨 버린 것입니다. 이 글은 공개된 org-ruby 소스와 GitLab이 직접 낸 탐지 문서를 열어, 이 CVSS 10.0 취약점의 근본원인·공격 흐름·영향을 우리 관점에서 분석합니다.

사용자가 넣은 값이 “데이터”로만 쓰이면 안전합니다. 문제는 그 값이 어느 순간 “코드”가 될 때입니다. SQL 인젝션은 사용자 문자열이 질의가 되고, 템플릿 인젝션은 사용자 문자열이 템플릿이 됩니다. 이 글의 버그는 같은 부류의 교과서적 사례입니다 — 사용자가 쓴 글자가 정규식(코드)이 되어, 그 정규식을 컴파일하던 엔진의 내부 카운터를 넘겨 메모리를 망가뜨렸습니다. 한 틈은 고쳐졌지만, “입력을 정규식으로 만든다”는 설계는 GitLab 한 곳에서만 최근 한 달에 세 번 터졌습니다.

개요

GitLab은 2026년 10월 22일, 자체 관리형(self-managed) 인스턴스를 노리는 긴급(Critical) 패치를 냈습니다. 주력 취약점 CVE-2026-110264는 CVSS 10.0 (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H)으로, 인증 없이(PR:N) 원격에서 서버 임의 코드 실행(RCE)에 이릅니다. 영향 범위(Scope)가 바뀜(S:C)으로 잡힌 건, 취약한 컴포넌트를 넘어 GitLab 서버 프로세스 전체(git 계정 권한)가 장악된다는 뜻입니다. [확인]

공식 CWE 분류는 **CWE-190 (정수 오버플로/랩어라운드)**입니다. 다만 이 한 줄은 “산술이 어디서 넘쳤나”만 담습니다. 실제 피해 사슬은 세 가지가 맞물립니다 [확인]:

  • CWE-94 — 사용자 입력이 정규식 소스로 재주입(코드 생성 제어 미흡)
  • CWE-190 — 반복 횟수가 16비트 필드를 넘겨 랩어라운드
  • CWE-787 — 그 결과로 실행(매치) 단계에서 경계 밖 쓰기(Out-of-Bounds Write)

제보는 HackerOne 버그바운티를 통해 연구자 joaxcar가 했습니다. [확인]

영향받는 버전

제품취약 버전안전(패치) 버전
GitLab CE/EE7.0 이상 ~ 18.11.13 미만18.11.13
GitLab CE/EE19.0 ~ 19.0.10 미만19.0.10
GitLab CE/EE19.1 ~ 19.1.9 미만19.1.9
GitLab CE/EE19.2 ~ 19.2.9 미만19.2.9
GitLab CE/EE19.3 ~ 19.3.6 미만19.3.6
GitLab CE/EE19.4 ~ 19.4.3 미만19.4.3
GitLab CE/EE19.5 ~ 19.5.1 미만19.5.1

GitLab.com과 GitLab Dedicated는 이미 패치됐습니다. 조치가 필요한 건 자체 관리형 인스턴스입니다. 18.11.13 미만 백포트 라인까지 패치가 제공된 점으로 보아, 아주 오래된 7.x~18.10 사용자는 지원 라인(최소 18.11.13)으로 올리라는 뜻으로 읽힙니다. [분석]

기술적 배경

Org-mode는 Emacs에서 온 문서 형식(.org)으로, 할 일 목록·메모·문서를 평문으로 적는 마크업입니다. GitLab은 저장소의 .org 파일을 웹에서 볼 때 이를 HTML로 바꿔 보여주는데, 이때 **org-ruby**라는 서드파티 젬(gem, 버전 0.9.12)을 씁니다. [확인]

Org-mode에는 TODO 키워드를 사용자가 직접 정의하는 기능이 있습니다. 문서 맨 위에 #+TODO: 줄을 적으면, 그 단어들이 할 일 상태(예: TODO, DONE, 사용자가 만든 임의 단어)로 쓰입니다. org-ruby는 헤드라인(제목 줄)을 파싱할 때, 첫 단어가 이 키워드인지 확인하고 맞으면 제거합니다.

여기서 핵심은 정규식 엔진입니다. Ruby는 Onigmo라는 정규식 엔진을 코어에 기본 내장합니다(Oniguruma에서 갈라져 나와 CRuby 2.0부터 번들된 표준 컴포넌트입니다 — GitLab이 따로 포크해 만든 게 아닙니다 [확인]). Onigmo는 정규식을 컴파일할 때, “이게 몇 번째 반복 구문인가”를 가리키는 repeat ID를 내부에 저장합니다. 그런데 이 ID를 담는 칸이 부호 있는 16비트(signed 16-bit) — 즉 최대 32,767까지만 담깁니다. 이 작은 칸이 이 사건의 무대입니다.

취약점 발생 원인 [패스 A — 구조/정적: 미흡점 열거]

이 취약점은 하나의 큰 실수가 아니라, 네 겹의 작은 틈이 순서대로 포개진 결과입니다.

A-1. 사용자 키워드를 escape 없이 정규식 소스로 재주입 (CWE-94)

org-ruby는 TODO 키워드 목록을 만들 때는 각 단어를 정규식 메타문자로부터 escape 합니다. 그런데 그 다음, 매치된 키워드를 제거하려고 두 번째 정규식을 새로 만들 때는 그 단어를 날것 그대로 집어넣습니다.

# 개념용 단순화 — 실제 페이로드/원본 코드 아님
def parse_keywords
  # ... 첫 단어가 키워드 정규식에 매치되면 @keyword 에 담긴다 ...
  @keyword = words[0]
  # ↓ @keyword(사용자 문자열)가 escape 없이 새 정규식의 소스가 된다
  @headline_text.sub!(Regexp.new("^#{@keyword}\\s*"), "")
end

실제 콜사이트 요약 인용 — org-ruby lib/org-ruby/headline.rb의 핵심 한 줄을 요약한 것입니다(완성형 페이로드 아님).

  • 어디: org-ruby headline.rb의 키워드 파싱 지점.
  • 없는 검증: @keyword를 정규식 소스로 쓰기 전에 escape 하거나, 길이·복잡도를 제한하지 않음.
  • 그래서 가능해지는 것: 공격자가 .org 문서의 #+TODO: 줄에 매치되도록 조작한 “키워드”를 넣으면, 그 문자열이 통째로 정규식 패턴이 됩니다 — 즉 공격자가 컴파일될 정규식의 모양을 직접 설계할 수 있습니다.

A-2. 반복 횟수 상한 검증 부재 → 16비트 repeat ID 랩어라운드 (CWE-190)

A-1 덕분에 공격자가 정규식에 반복 구문을 아주 많이 넣을 수 있게 되자, Onigmo의 16비트 칸이 문제가 됩니다. 반복 구문이 32,767개를 넘으면 repeat ID가 16비트 한계를 넘어 음수나 작은 값으로 순환(wrap) 합니다. [확인]

  • 어디: Onigmo 컴파일러가 repeat ID를 할당하는 지점.
  • 없는 검증: “repeat 개수가 필드 상한을 넘는가”를 컴파일 전에 막는 검사.
  • 그래서 가능해지는 것: 컴파일은 성공한 것처럼 끝나지만, 내부 바이트코드에는 잘못된(음수/범위 밖) ID가 박힙니다.

A-3. 렌더링 입력에 크기·복잡도 가드 부재

GitLab은 마크업 렌더링에 **시간 예산(timeout)**만 걸어 둡니다. “너무 오래 걸리면 중단”은 있어도, “이 키워드가 비정상적으로 길거나 반복이 많다”는 입력 자체의 이상을 걸러내는 가드는 없었습니다. [분석]

  • 그래서 가능해지는 것: 악성 .org가 렌더 파이프라인 끝까지 �‍러들어가 Onigmo에 도달합니다.

A-4. 랩어라운드된 ID가 매치 단계에서 포인터 연산에 사용 (CWE-787)

여기서 advisory의 표현을 정확히 봐야 합니다. 공식 권고는 “정규식을 컴파일할 때(compiling) 정수 오버플로”라고 적었는데, 이는 산술이 넘치는 지점(컴파일)을 가리킬 뿐입니다. 실제 메모리 손상은 매치(match) 단계에서 터집니다 [확인] — 잘못된 repeat ID로 만들어진 옵코드가 실행되며 내부 배열을 음수/범위 밖으로 인덱싱해 버퍼 바깥에 씁니다. GitLab 탐지 문서의 크래시 스택도 컴파일 함수가 아니라 매치 함수(match_at → onig_search_gpos) 쪽을 가리킵니다.

취약점 공격 과정

공격 과정 요약

[공격자]  악성 #+TODO: 줄이 든 .org 파일을 공개 프로젝트에 올림
   │
   ▼
[피해자측 트리거]  누군가(혹은 봇·프리뷰)가 그 .org blob을 "본다"
   │              → 인증 불필요. public 프로젝트면 아무나 열람 = 렌더 트리거
   ▼
[GitLab 마크업 파이프라인]  Gitlab::OtherMarkup → org-ruby 로 .org 렌더
   │
   ▼
[org-ruby]  헤드라인 파싱 중 사용자 키워드를 Regexp.new 에 raw 재주입 (A-1)
   │
   ▼
[Onigmo 컴파일]  32,767 초과 반복 → 16비트 repeat ID wrap (A-2) — "성공"한 척 끝남
   │
   ▼
[Onigmo 매치]  잘못된 ID로 버퍼 밖 쓰기(OOB write) (A-4)
   │
   ▼
[격상]  준비된 힙 배치 + 캐리어로 git 계정 코드 실행

상세 공격 과정 [패스 B — 흐름/동적: 호출 순서]

  1. 공격자가 공개 프로젝트에 조작된 #+TODO: 줄을 가진 .org 파일을 둡니다. 특별한 권한이 필요 없습니다 — 공개 프로젝트를 만들 수만 있으면 됩니다. [확인]
  2. 그 파일이 웹에서 렌더링되는 순간(저장소에서 파일을 열거나 프리뷰가 뜰 때) GitLab 마크업 파이프라인(Gitlab::OtherMarkup, gitlab-markup 젬)이 org-ruby를 호출합니다. 보는 사람이 로그인했는지와 무관합니다 [확인] — 공개 blob은 누구에게나 렌더됩니다. 렌더를 트리거하는 컨트롤러 경로도 GitLab 탐지 문서의 샘플 로그에 그대로 드러나 있습니다 — 저장소 파일 보기는 Projects::BlobController#show, 프리뷰는 #preview 경로입니다. [확인]
  3. org-ruby가 헤드라인을 파싱하며 A-1의 Regexp.new("^#{@keyword}…")를 호출합니다. 공격자 문자열이 정규식 소스가 됩니다. [확인]
  4. Onigmo가 이 정규식을 컴파일합니다. 32,767개를 넘는 반복이 16비트 repeat ID를 랩어라운드시킵니다(A-2). 컴파일은 오류 없이 끝난 것처럼 보입니다. [확인]
  5. 곧바로 매치가 실행됩니다(sub!가 내부적으로 매치를 돌립니다). 잘못된 ID가 포인터 연산에 쓰여 할당된 버퍼 밖에 씁니다(A-4). [확인]
  6. 공격자가 요청 내용으로 힙 배치를 어느 정도 다듬어 두면, 이 한 번의 경계 밖 쓰기를 함수 포인터·디스패치 테이블 변조로 이어 git 계정 권한의 코드 실행까지 격상시킬 수 있습니다 — 다만 이 격상 단계의 구체적 성공 조건은 공개 범위 밖이며, 일반적 메모리 손상 격상 메커니즘에 근거한 설명입니다. [분석] 이 글은 격상에 쓰이는 완성형 익스플로잇·페이로드를 싣지 않습니다(랩 공개 윤리).

탐지상 중요한 성질 [확인]: GitLab은 “성공한 익스플로잇은 조용하며, 안정적으로 크래시를 내지 않는다”고 밝혔습니다. 즉 크래시·타임아웃은 실패한 시도의 부산물일 뿐, 탐지는 확정이 아니라 보강 신호로 봐야 합니다.

미흡점 ↔ 공격 흐름 매핑

미흡점 (패스 A)발화 지점 (패스 B)여는 프리미티브다음으로 잇는 것
A-1 키워드 raw 재주입3단계 Regexp.new 호출공격자 통제 정규식 소스반복 개수를 공격자가 설계
A-2 16비트 ID 랩어라운드4단계 Onigmo 컴파일잘못된 내부 repeat ID”성공”으로 위장된 손상 바이트코드
A-3 입력 크기 가드 부재2단계 렌더 진입악성 입력이 엔진까지 도달(A-1·A-2가 실제로 실행됨)
A-4 ID의 포인터 사용5단계 매치 실행경계 밖 쓰기(OOB write)힙 배치→코드 실행 격상

이 표가 “빠진 검증의 목록(정적)“과 “한 요청이 그걸 어떻게 꿰는가(동적)“를 잇는 다리입니다.

취약점 패치 + 패치 완전성 평가

GitLab 내부 이슈(work_items/633633)의 실제 코드 diff는 패치 후 90일간 비공개입니다(GitLab 표준 공개 정책). 따라서 “GitLab이 org-ruby 호출부에서 키워드 길이를 제한했는지, 입력을 escape 했는지, 혹은 Onigmo 쪽을 손봤는지”의 구체는 지금 공개 범위 밖입니다 [확인=비공개 사실]. 아래 완전성 평가는 그래서 가설이며, 동적 확증 전까지 단정하지 않습니다.

Q1 — 근본원인을 막았나, 증상만 막았나? 미확인입니다. 패치 구현이 공개되지 않아, 애플리케이션 레벨 필터(증상 완화)인지 근본 설계 수정인지 알 수 없습니다. 아래 Q3의 전례를 보면 이 질문은 특히 조심해야 합니다. [분석]

Q2 — 같은 뿌리의 다른 입구(형제 싱크)는? 있습니다, 그것도 실증된 형태로. 같은 Onigmo 정수 오버플로 계열이 최근 한 달에 세 번 패치됐습니다 [확인]:

CVE공개일입력 경로넘친 필드인증CVSS
CVE-2026-935772026-09-24CI rules:*:regexp32비트 점프 오프셋필요9.9
CVE-2026-972292026-10-13CI rules:*:regexp (93577 우회)32비트 점프 오프셋불필요10.0
CVE-2026-1102642026-10-22Org-mode #+TODO:16비트 repeat ID불필요10.0

세 건 모두 “고정폭 정수 필드 + 사용자 통제 정규식 컴파일”이라는 한 뿌리에서 나왔지만, 넘친 필드와 입력 경로가 각각 다릅니다. 즉 Onigmo 컴파일 경로에는 “사용자가 넘길 수 있는 작은 칸”이 여러 곳 있었고, GitLab은 발견되는 족족 막아 왔습니다. org-ruby 안에 사용자 문자열을 정규식으로 재주입하는 또 다른 지점이 남았는지는 확인되지 않았습니다 — 가능성은 열려 있습니다. [분석]

Q3 — 조용한/불완전한 재패치는? 전례가 있습니다. CVE-2026-97229는 그 설명문 자체가 “불충분한 정규식 검증”이라 적었고, 탐지 문서도 이를 “93577의 우회(bypass)“라 부릅니다 — 한 번 패치한 것이 뚫렸다는 뜻입니다 [확인]. 따라서 110264의 패치가 근본 수정인지는 “검증 필요”로 보는 게 안전합니다.

선순환 후보(가설). 위 Q2·Q3 신호는 CVE 헌팅 후보로 넘길 가치가 있습니다: ① org-ruby(및 GitLab 마크업 파이프라인)에서 사용자 문자열을 Regexp.new로 재주입하는 다른 콜사이트, ② Onigmo 컴파일 경로에서 16비트 repeat ID 외에 사용자 영향을 받는 다른 고정폭 필드. 둘 다 정적 신호일 뿐이며, 동적 확증 전에는 가설입니다.

대응방안

1) 안전 버전으로 업데이트 (최우선). 위 표의 패치 버전(자신의 라인에 맞는 18.11.13 / 19.0.10 / 19.1.9 / 19.2.9 / 19.3.6 / 19.4.3 / 19.5.1) 이상으로 올리십시오. CVSS 10.0·미인증 RCE이므로 자체 관리형 인스턴스는 지체 없이 적용해야 합니다.

2) 즉시 업데이트가 어려울 때의 차선.

  • .org 파일 렌더링이 꼭 필요하지 않다면, 마크업 렌더에서 Org-mode 처리를 일시 비활성/제한하는 것을 검토하십시오(기능 축소로 공격면 제거).
  • 공개 프로젝트에서 신뢰할 수 없는 사용자가 .org 파일을 올리고 렌더되게 하는 흐름을 한시적으로 차단/검토.
  • 가능하면 마크업 렌더 워커를 더 강하게 격리(자원·권한 축소)해 OOB write가 전체 프로세스로 번지는 영향을 줄이십시오.

3) 탐지 관점. GitLab 보안팀이 공개 Sigma 규칙(gitlab-security-oss/tldr)을 냈습니다 — Onigmo 네이티브 워커 크래시 시그니처, 그리고 Org-mode 렌더 오류/타임아웃 급증(exceptions_json.log의 extra.class_name: "OtherMarkup") 패턴입니다. 단 앞서 말했듯 성공한 공격은 조용하므로, 이 신호는 “시도의 흔적”을 잡는 보강용으로 쓰고 단독 확정 근거로 삼지 마십시오.

왜 이게 반복되는가 — 논평

이 버그의 뿌리는 메모리 안전(16비트 칸)이 아니라 그 한 층 위에 있습니다. 진짜 출발점은 “사용자가 쓴 문자열을 정규식(코드)으로 만든다”는 설계입니다. 입력이 데이터로만 머물렀다면 16비트 칸은 평생 넘칠 일이 없었습니다. 공격자가 “반복을 몇 개 넣을지”까지 직접 설계할 수 있게 된 건, 입력이 코드가 되는 경계를 넘었기 때문입니다.

그래서 이건 우리가 여러 번 본 계열과 한 뿌리입니다 — 사용자 입력이 질의가 되는 SQL 인젝션, 템플릿이 되는 SSTI(서버 사이드 템플릿 인젝션), 그리고 입력이 필터의 해석을 벌리는 필터 역설계와 같은 사고방식입니다. 방어의 급소도 같습니다: 신뢰할 수 없는 입력을 “실행 가능한 문법”으로 승격시키지 말 것. 꼭 해야 한다면 escape 하거나, 길이·복잡도에 확정적 상한을 두어야 합니다.

흥미로운 지점은 GitLab이 같은 뿌리를 한 달에 세 번 막았다는 사실입니다. 이건 “하나를 고쳐도 같은 뿌리의 형제들이 줄줄이 남는다”는 오래된 교훈을 정확히 보여줍니다 — 취약점 하나를 확증하면 같은 근본원인의 모든 호출부(형제 싱크)를 끝까지 훑어야 하는 이유입니다. 한 콜사이트를 막는 패치는 다음 콜사이트를 막지 못합니다. 근본은 “입력→정규식” 승격 자체이기 때문입니다.

결론

사용자의 한 단어가 정규식이 되고, 그 정규식이 엔진의 16비트 칸을 넘겨, 로그인 없이 서버를 장악했습니다. 한 틈(이 콜사이트)은 고쳐졌지만, “입력을 코드로 만든다”는 사고방식은 CI 규칙에서 두 번, Org-mode에서 한 번 — 다른 자리에서 계속 나왔습니다. 그래서 우리는 도구가 아니라 방법을 남깁니다: 입력이 코드가 되는 경계를 찾고, 그 경계에 확정적 상한을 두는 것.

GitLab 내부 패치 diff가 90일 뒤 공개되면, 이 패치가 근본을 막았는지(Q1) 다시 열어 확인할 값어치가 있습니다.

References / 참고자료

  1. (재구성한 원 분석) GitLab, “GitLab 19.5.1, 19.4.3, 19.3.6 critical patch release” — 이 글이 재구성한 공식 패치 권고(CVE 설명·영향/안전 버전·CVSS). https://docs.gitlab.com/releases/patches/patch-release-gitlab-19-5-1-released/
  2. GitLab Security, “Onigmo native worker crash” 탐지 문서 — 세 CVE의 메커니즘·크래시 스택·Sigma 규칙. https://gitlab.com/gitlab-security-oss/tldr
  3. GitLab Security, “Org mode render error burst” 탐지 문서 — 미인증 .org 렌더 접근면·컨트롤러 경로(Projects::BlobController#show)·타임아웃 로그 신호. https://gitlab.com/gitlab-security-oss/tldr/-/raw/main/Detections/platforms/GitLab/Vulnerability%20Detections/org_mode_render_error_burst.md
  4. MITRE CVE-2026-110264 / NVD — CWE-190, CVSS 벡터. https://www.cve.org/CVERecord?id=CVE-2026-110264
  5. 형제 CVE: CVE-2026-93577, CVE-2026-97229 (CI rules:*:regexp 경로, 원조·우회 관계).
  6. org-ruby gem 소스 — 키워드 파싱의 Regexp.new 재주입 구조(개념 확인용). https://github.com/wallyqs/org-ruby

이 글은 위 원 분석들을 우리 관점에서 다시 쓰고 논평을 더한 것입니다. 특정 문장·코드를 그대로 옮기지 않았으며, 자세한 재현 절차나 완성형 페이로드는 싣지 않습니다(랩 공개 윤리).

배지: [확인] = 1차출처(공식 권고·탐지 문서·CVE 레코드·오픈소스)로 확인된 사실 · [분석] = 우리 추론. 이 글은 우리 랩에서 재현하지 않았습니다(재현 배지 없음).