| 항목 | 내용 |
|---|---|
| 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.go의 checkIP() — 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에는 사용자가 지정한 URL로 서버가 직접 HTTP 요청을 보내는 기능이 최소 두 곳 있다.
- 웹훅(Webhook): 저장소에 이벤트(push, issue 생성 등)가 발생하면, 저장소 설정에 등록된 URL로 Gitea 서버가 HTTP POST 요청을 보낸다. 응답 상태 코드·헤더·본문(최대 1MiB)은 저장 되어 웹훅 "전송 이력(delivery history)" 화면에서 그대로 조회할 수 있다.
- 저장소 마이그레이션: 관리자·사용자가 "다른 Git 호스팅 URL에서 이 저장소를 가져오라"고 지시하면, Gitea 서버가 그 URL로 접속해 클론을 수행한다.
두 기능 모두 "서버가 사용자 대신, 사용자가 정한 목적지로 요청을 보낸다"는 공통 구조를 가진다. 이 구조 자체가 SSRF의 전형적인 공격 표면이다 — 공격자가 목적지를 임의로 정할 수 있다면, 원래는 외부에서 접근할 수 없는 내부망 서비스나 클라우드 인프라 API에 서버를 대신 접속시켜 그 응답을 훔쳐볼 수 있기 때문이다.
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"는 이 허용목록의 정확성에 곧바로 보안을 의존하게 된다.
허용목록 검사를 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는 이 시점의 판정 로직이 아니라, 그 판정이 참조하는 "무엇이 사설/예약 대역인가"의 정의가 불완전했다는 데서 발생했다.
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:a9fe는 169.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 방어가 실제로 필요로 하는 "공인 인터넷 호스트가 아님(=서버가 접속해서는 안 됨)"이라는 의미가 서로 다르다는 사실이 이번 취약점의 근본 원인이다.
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.1은 IsGlobalUnicast() == 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이 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_LIST나 migrations.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도 동일한 변경으로 보이지만 대상 브랜치 차이로 인한 세부 차이가 있는지는 별도로 대조하지 않았다.
- CVE-2026-22874 — NVD — CVSS 9.6, CWE-918, 공개일
- GHSA-2r5c-gw76-rh3w — 원 신고자(JLLeitschuh, braze-inc)의 상세 분석, 취약 코드 위치, PoC 테스트 결과
- PR #38059 — go-gitea/gitea (release/v1.26 백포트) — 실제 수정 diff,
reservedIPNets/isPrivateIP도입,TestReservedRanges - PR #38039 — go-gitea/gitea (main) — 원본 수정 PR
- Gitea 1.26.3/1.26.4 릴리스 안내 — 공식 수정 버전 확인
- 1.26.2 취약 코드 — hostmatcher.go — 패치 전
checkIP modules/hostmatcher/http.go—NewDialContext— DNS resolve 이후 시점에 허용목록을 검사하는 DialContext 설계services/webhook/deliver.go— 웹훅 기본 허용목록(external)과 응답 캡처 로직services/migrations/migrate.go— 마이그레이션 기본 허용목록 설정- Go 표준 라이브러리
net.IP.IsPrivate()— RFC 1918/RFC 4193만 인식한다는 공식 문서