Skip to content

Latest commit

 

History

History
257 lines (208 loc) · 16.7 KB

File metadata and controls

257 lines (208 loc) · 16.7 KB

"사설 IP만 막으면 된다"는 착각 — Gitea SSRF 허용목록의 예약 대역 누락 (CVE-2026-22874)

메타데이터

항목 내용
CVE ID CVE-2026-22874
영향 제품/버전 Gitea 1.26.2 이하 (Webhook 전송, 저장소 마이그레이션 기능을 사용하는 모든 설치)
CVSS v3.1 9.6 (Critical) — AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N (NVD)
CWE CWE-918 (Server-Side Request Forgery)
공개일 2026-07-03 (NVD) / 2026-07-21 (GHSA 상세 공개)
수정 버전 1.26.3
취약 지점 modules/hostmatcher/hostmatcher.gocheckIP()MatchBuiltinExternal/MatchBuiltinPrivate 판정

개요

Gitea는 웹훅(Webhook) 전송과 저장소 마이그레이션(다른 Git 호스팅에서 저장소를 가져오는 기능)을 수행할 때 서버가 사용자가 지정한 URL로 직접 HTTP 요청을 보낸다. 이런 "서버가 대신 요청을 보내주는" 기능은 태생적으로 SSRF(Server-Side Request Forgery) 위험을 안고 있어서, Gitea는 요청 대상 IP가 "외부(external)"인지 "사설(private)"인지를 판정하는 허용목록 메커니즘(hostmatcher)을 두고 기본값으로 외부 주소만 허용한다. 문제는 이 판정이 Go 표준 라이브러리의 net.IP.IsPrivate()에만 의존했다는 데 있다. IsPrivate()는 RFC 1918 사설 대역과 RFC 4193 IPv6 ULA만 인식할 뿐, 클라우드 메타데이터 엔드포인트·CGNAT·IPv6 전환 기술(NAT64, Teredo, 6to4) 등 "사설은 아니지만 공인 인터넷도 아닌" 대역은 전혀 걸러내지 못한다. 그 결과 웹훅을 만들 수 있는 인증된 사용자는 AWS IMDS나 Azure WireServer 같은 클라우드 메타데이터 엔드포인트에 요청을 보내고, 그 응답을 웹훅 전송 이력 화면에서 그대로 읽어낼 수 있었다. 1.26.3은 IsPrivate() 위에 예약 대역 목록을 추가로 겹쳐 쌓아 이 틈을 막았다.

사전 지식

Gitea가 "서버 대신 요청을 보내주는" 두 가지 기능

Gitea에는 사용자가 지정한 URL로 서버가 직접 HTTP 요청을 보내는 기능이 최소 두 곳 있다.

  • 웹훅(Webhook): 저장소에 이벤트(push, issue 생성 등)가 발생하면, 저장소 설정에 등록된 URL로 Gitea 서버가 HTTP POST 요청을 보낸다. 응답 상태 코드·헤더·본문(최대 1MiB)은 저장 되어 웹훅 "전송 이력(delivery history)" 화면에서 그대로 조회할 수 있다.
  • 저장소 마이그레이션: 관리자·사용자가 "다른 Git 호스팅 URL에서 이 저장소를 가져오라"고 지시하면, Gitea 서버가 그 URL로 접속해 클론을 수행한다.

두 기능 모두 "서버가 사용자 대신, 사용자가 정한 목적지로 요청을 보낸다"는 공통 구조를 가진다. 이 구조 자체가 SSRF의 전형적인 공격 표면이다 — 공격자가 목적지를 임의로 정할 수 있다면, 원래는 외부에서 접근할 수 없는 내부망 서비스나 클라우드 인프라 API에 서버를 대신 접속시켜 그 응답을 훔쳐볼 수 있기 때문이다.

hostmatcher: 허용목록/차단목록으로 요청 대상을 제한하는 장치

Gitea는 이 위험을 줄이기 위해 modules/hostmatcher 패키지로 "이 IP/호스트가 허용목록에 있는가"를 판정한다. 설정에서 직접 CIDR이나 도메인 패턴을 나열할 수도 있지만, 별도 설정이 없으면 세 가지 내장(built-in) 카테고리 중 하나를 기본값으로 쓴다.

// modules/hostmatcher/hostmatcher.go (패치 전)
const MatchBuiltinExternal = "external"  // 공인 인터넷의 모든 호스트
const MatchBuiltinPrivate  = "private"   // RFC 1918 + RFC 4193 (LAN/인트라넷)
const MatchBuiltinLoopback = "loopback"  // 127.0.0.0/8, ::1

웹훅과 마이그레이션 둘 다 **별도 설정이 없으면 허용목록 기본값이 external**이다.

// services/webhook/deliver.go — Init()
allowedHostListValue := setting.Webhook.AllowedHostList
if allowedHostListValue == "" {
    allowedHostListValue = hostmatcher.MatchBuiltinExternal
}
// services/migrations/migrate.go — Init()
allowList = hostmatcher.ParseSimpleMatchList(..., setting.Migrations.AllowedDomains)
if allowList.IsEmpty() {
    // the default policy is that migration module can access external hosts
    allowList.AppendBuiltin(hostmatcher.MatchBuiltinExternal)
}

즉 관리자가 이 값을 별도로 좁히지 않은 "기본 설정 그대로의 Gitea"는 이 허용목록의 정확성에 곧바로 보안을 의존하게 된다.

DNS 리바인딩을 막기 위해 검사는 "연결 직전"에, resolve된 IP를 대상으로 한다

허용목록 검사를 URL 문자열의 호스트명에만 적용하면 DNS 리바인딩(공격자가 도메인의 DNS 응답을 나중에 내부 IP로 바꿔치기)에 뚫릴 수 있다. Gitea는 이를 피하려고 검사를 HTTP 트랜스포트의 DialContext 안, 즉 DNS가 실제로 resolve된 뒤 TCP 연결을 열기 직전 시점에 수행한다.

// modules/hostmatcher/http.go — NewDialContext
Control: func(network, ipAddr string, c syscall.RawConn) error {
    ...
    tcpAddr, err := net.ResolveTCPAddr(network, ipAddr)   // 이미 resolve된 IP:PORT
    ...
    if !allowList.IsEmpty() {
        if !allowList.MatchHostOrIP(host, tcpAddr.IP) {
            return fmt.Errorf("... can only call allowed HTTP servers ...")
        }
    }
    return blockedError
},

이 설계 자체는 견고하다. 이번 CVE는 이 시점의 판정 로직이 아니라, 그 판정이 참조하는 "무엇이 사설/예약 대역인가"의 정의가 불완전했다는 데서 발생했다.

"사설(private)"과 "공인 라우팅 가능(publicly routable)"은 서로 다른 개념이다

IPv4/IPv6 주소 공간에는 RFC 1918(10.0.0.0/8 등)처럼 누구나 아는 사설 대역 말고도, 특정 목적으로 예약되어 있어 "공인 인터넷 호스트"로 취급하면 안 되는 대역이 여럿 있다.

대역 근거 용도
100.64.0.0/10 RFC 6598 CGNAT(통신사가 여러 가입자를 NAT로 묶을 때 쓰는 중간 대역)
168.63.129.16/32 Azure 내부 규약 Azure VM이 호스트 관리 API를 호출하는 WireServer 메타데이터 엔드포인트
169.254.169.254 클라우드 관례 AWS/GCP 등의 인스턴스 메타데이터 서비스(IMDS) — 자격증명 등 민감 정보 제공
192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 RFC 5737 문서화 예시 전용(TEST-NET-1/2/3)
198.18.0.0/15 RFC 2544 네트워크 장비 벤치마킹 전용
192.88.99.0/24 RFC 7526(폐기됨) 6to4 릴레이 애니캐스트
64:ff9b::/96 RFC 6052 NAT64 — IPv4 주소를 IPv6 안에 그대로 품는다(64:ff9b::a9fe:a9fe169.254.169.254를 품은 것)
2001::/32 RFC 4380 Teredo IPv6 터널링
2002::/16 RFC 3056 6to4
2001:db8::/32 RFC 3849 문서화 전용

Go 표준 라이브러리의 net.IP.IsPrivate()는 이 중 RFC 1918과 RFC 4193(IPv6 ULA, fc00::/7) 딱 두 가지만 인식한다. 나머지는 전부 "사설이 아님" 즉 IsPrivate() == false로 판정된다. 이 함수 이름의 "private"과, SSRF 방어가 실제로 필요로 하는 "공인 인터넷 호스트가 아님(=서버가 접속해서는 안 됨)"이라는 의미가 서로 다르다는 사실이 이번 취약점의 근본 원인이다.

취약점 분석

checkIP(): IsPrivate()를 곧바로 "이건 외부다"의 반대 조건으로 사용

external/private 두 내장 카테고리는 모두 net.IP.IsPrivate()를 직접 호출해서 판정했다.

// modules/hostmatcher/hostmatcher.go (1.26.2, 패치 전)
func (hl *HostMatchList) checkIP(ip net.IP) bool {
    ...
    for _, builtin := range hl.builtins {
        switch builtin {
        case MatchBuiltinExternal:
            if ip.IsGlobalUnicast() && !ip.IsPrivate() {
                return true   // "사설이 아니면 외부다" — 예약 대역도 여기 걸린다
            }
        case MatchBuiltinPrivate:
            if ip.IsPrivate() {
                return true
            }
        ...
        }
    }
    ...
}

ip.IsGlobalUnicast()는 멀티캐스트·미지정 주소 같은 특수 케이스만 걸러낼 뿐, 위 표의 예약 대역들은 전부 통과시킨다. 168.63.129.16이나 100.64.0.1IsGlobalUnicast() == true이면서 IsPrivate() == false이므로, MatchBuiltinExternal 판정에서 조건 없이 "외부(허용)"로 분류된다.

[패치 전 판정 흐름 — 대상 IP: 168.63.129.16 (Azure WireServer)]

  checkIP(168.63.129.16)
        │
        ├─ ip.IsGlobalUnicast()  →  참  (사설/루프백/멀티캐스트가 아님)
        ├─ ip.IsPrivate()        →  거짓 (RFC1918/4193에 없음)
        ▼
  MatchBuiltinExternal 조건: true && !false = 참
        ▼
  "external" 허용목록 통과  ──▶  웹훅 서버가 168.63.129.16 으로 실제 연결

공격 시나리오: 웹훅 URL로 클라우드 메타데이터를 찌르고 이력 화면으로 읽는다

웹훅을 만들거나 수정할 수 있는 인증된 사용자는 저장소 설정에서 웹훅 URL을 자유롭게 지정할 수 있다. 이 URL이 168.63.129.16(Azure WireServer)이나 64:ff9b::a9fe:a9fe(NAT64로 169.254.169.254, 즉 AWS IMDS를 품은 IPv6 주소)를 가리키면, 위에서 본 대로 허용목록을 그대로 통과한다. Gitea 서버는 실제로 그 주소에 HTTP 요청을 보내고, 응답을 저장한다.

// services/webhook/deliver.go
resp, err := webhookHTTPClient.Do(req.WithContext(ctx))
...
t.ResponseInfo.Status = resp.StatusCode
for k, vals := range resp.Header {
    t.ResponseInfo.Headers[k] = strings.Join(vals, ",")
}
p, err := util.ReadWithLimit(resp.Body, 1024*1024)   // 최대 1MiB
t.ResponseInfo.Body = string(p)

이렇게 저장된 상태 코드·헤더·최대 1MiB 본문은 저장소 설정의 웹훅 "전송 이력" 화면(templates/repo/settings/webhook/history.tmpl)에서 그대로 렌더링된다. 즉 이 SSRF는 응답 내용을 전혀 볼 수 없는 "블라인드 SSRF"가 아니라, 공격자가 대상 서비스의 응답 본문까지 고스란히 읽어낼 수 있는 풀 리스폰스(full-response) SSRF다. 클라우드 메타데이터 엔드포인트가 임시 자격증명이나 인스턴스 메타데이터를 반환하는 서비스라는 점을 생각하면, CVSS 벡터의 C:H(기밀성 높음)·S:C(Scope Changed — Gitea 프로세스의 권한 경계를 넘어 클라우드 인프라까지 영향)와 정확히 들어맞는다. 저장소 마이그레이션 기능도 같은 기본 허용목록(external)을 쓰므로 공격 표면이 동일하게 넓어진다.

수정 방법

1.26.3은 net.IP.IsPrivate()가 놓치는 예약 대역 목록을 코드에 직접 나열하고, 두 내장 카테고리 모두 IsPrivate()와 이 목록을 함께 확인하는 isPrivateIP() 헬퍼를 거치도록 바꿨다.

// modules/hostmatcher/hostmatcher.go (1.26.3, 패치 후)
var reservedIPNets = sync.OnceValue(func() []*net.IPNet {
    var nets []*net.IPNet
    for _, cidr := range []string{
        "100.64.0.0/10",    // RFC 6598 Carrier-Grade NAT
        "168.63.129.16/32", // Azure WireServer metadata endpoint
        "192.0.0.0/24",     // RFC 6890 IETF protocol assignments
        "192.0.2.0/24",     // RFC 5737 TEST-NET-1
        "192.88.99.0/24",   // RFC 7526 6to4 relay anycast (deprecated)
        "198.18.0.0/15",    // RFC 2544 benchmarking
        "198.51.100.0/24",  // RFC 5737 TEST-NET-2
        "203.0.113.0/24",   // RFC 5737 TEST-NET-3
        "100::/64",         // RFC 6666 discard-only
        "64:ff9b::/96",     // RFC 6052 NAT64 (can embed IPv4 such as 169.254.169.254)
        "64:ff9b:1::/48",   // RFC 8215 local-use NAT64
        "2001::/32",        // RFC 4380 Teredo tunneling (embeds IPv4)
        "2001:10::/28",     // RFC 4843 ORCHID (deprecated)
        "2001:20::/28",     // RFC 7343 ORCHIDv2
        "2001:db8::/32",    // RFC 3849 documentation
        "2002::/16",        // RFC 3056 6to4 (embeds IPv4)
    } {
        _, ipNet, err := net.ParseCIDR(cidr)
        ...
        nets = append(nets, ipNet)
    }
    return nets
})

func isPrivateIP(ip net.IP) bool {
    if ip.IsPrivate() {
        return true
    }
    for _, ipNet := range reservedIPNets() {
        if ipNet.Contains(ip) {
            return true
        }
    }
    return false
}

checkIP()ip.IsPrivate()를 직접 부르던 두 지점을 모두 isPrivateIP(ip)로 바꿨다.

case MatchBuiltinExternal:
    if ip.IsGlobalUnicast() && !isPrivateIP(ip) {
        return true
    }
case MatchBuiltinPrivate:
    if isPrivateIP(ip) {
        return true
    }

핵심은 IsPrivate()를 대체한 게 아니라 그 위에 겹쳐 쌓았다는 점이다. 주석에도 명시돼 있듯, 이렇게 하면 Go 표준 라이브러리가 나중에 IsPrivate()의 인식 범위를 넓히더라도 자동으로 반영되면서, 지금 당장 빠진 대역은 별도 목록으로 확실히 막는다.

[패치 후 판정 흐름 — 대상 IP: 168.63.129.16 (Azure WireServer)]

  checkIP(168.63.129.16)
        │
        ├─ ip.IsGlobalUnicast()  →  참
        ├─ isPrivateIP(168.63.129.16)
        │     ├─ ip.IsPrivate()        →  거짓
        │     └─ reservedIPNets 순회   →  168.63.129.16/32 에 포함  →  참
        ▼
  MatchBuiltinExternal 조건: true && !true = 거짓
        ▼
  "external" 허용목록 불통과  ──▶  웹훅/마이그레이션 요청이 여기서 차단됨
        (동시에 "private" 차단목록에는 이제 이 주소가 걸린다)

같은 커밋은 TestReservedRanges를 추가해 CGNAT·Azure WireServer·NAT64로 감싼 169.254.169.254·Teredo·6to4·문서화 대역이 더 이상 external에 걸리지 않고, 대신 private 목록에는 정상적으로 걸리는지 각각의 CIDR 경계값까지 포함해 검증한다. Gitea 운영자는 1.26.3 이상으로 업그레이드해야 하며, webhook.ALLOWED_HOST_LISTmigrations.ALLOWED_DOMAINS를 이미 커스텀 CIDR로 좁혀 둔 설치라면 그 설정과는 무관하게(내장 카테고리를 참조하는 경우에 한해) 이번 수정의 영향을 받는다.

미해결/불확실 지점

  • GHSA 상세 본문의 "Suggested Remediation"에는 원 신고자가 참고 사례로 든 CC-Tweaked 프로젝트의 차단목록도 언급되지만, Gitea가 실제로 채택한 목록(위 reservedIPNets)이 그 참고 목록과 완전히 같지는 않다. 어떤 항목을 취사선택했는지의 논의는 PR 코멘트에 있을 수 있으나 이 보고서에서는 병합된 diff와 GHSA 본문만 근거로 삼았다.
  • 두 개의 수정 PR(#38059 backport, #38173) 중 이 보고서는 병합된 diff를 직접 검증할 수 있었던 #38059(release/v1.26 백포트)를 기준으로 코드를 인용했다. 원본 PR #38039(main)의 diff도 동일한 변경으로 보이지만 대상 브랜치 차이로 인한 세부 차이가 있는지는 별도로 대조하지 않았다.

참고 자료