미니멀하고 메모리 안전한 Go rsync가 취약점을 피하는 방법
2025년 1월, 여러 보안 연구자들이 rsync의 6건 보안 취약점을 공개했습니다. 그중 일부는 임의 코드 실행과 파일 유출을 허용하는 것이었기에, 자연스럽게 제 gokrazy/rsync 구현이 영향을 받는지, 받는다면 어떻게 받는지 궁금해졌습니다. 현대적이고 메모리 안전한 프로그래밍 언어인 Go로 직접 (호환되지만 최소 기능만 갖춘) rsync를 구현한 것이 정말로 특정 유형의 보안 취약점 전체를 원천 차단했을까요?
이 심층 분석 글은 2025년 1월부터 준비해 왔지만, 그 과정에서 아직 공개되지 않은 취약점을 추가로 발견하면서 지연되었습니다! “보안 취약점” 섹션에서는 이제 2025년 1월 배치와 2026년 5월 배치에 포함된 총 12건의 취약점을 모두 다룹니다.
프로덕션 환경에서 (업스트림, samba) rsync를 사용하고 있다면 3.4.3 이상 버전으로 업그레이드하십시오.
프로덕션 환경에서 gokrazy/rsync를 사용하고 있다면 v0.3.3 이상 버전으로 업그레이드하십시오.
세부적인 보안 이슈 내용은 건너뛰고 바로 다음으로 이동하셔도 좋습니다:
- Go 사용이 도움이 되었는지에 대한 결론.
- gokrazy/rsync 같은 최소 재구현이 도움이 되는지에 대한 결론.
- OpenBSD의
openrsync와의 비교(C로 작성). - Linux에서 활용할 수 있는 다층 방어 메커니즘.
- 결론.
배경: 제가 직접 만든 rsync
참고로 저는 2022년 6월에 rsync가 무엇인지, 어떻게 사용하는지, 어떻게 동작하는지에 대해 블로그 글을 쓴 바 있습니다. “rsync” 태그가 붙은 모든 글도 참고하십시오.
제가 직접 rsync를 만들게 된 원래 동기(당시에는 서버만 만들었고, 지금은 모든 방향을 지원합니다)는 빠른 패키지 관리를 위한 Linux 배포판 연구 프로젝트인 distri의 소프트웨어 패키지를 호스팅하기 위해서였습니다. 이를 router7, 즉 gokrazy라는 Go 어플라이언스 플랫폼을 기반으로 만든 작고 Go로 구현된 가정용 인터넷 라우터에서 호스팅하고 싶었습니다.
저는 지금도 원래 목적을 위해 여러 대의 gokrazy/rsync 서버를 운영 중이며, 그 외에도 많은 용도로 활용하고 있습니다! rsync를 (Go 프로그램에 링크해서 쓸 수 있는) 기본 요소로 사용할 수 있다는 점은 정말 유용합니다.
보안 취약점
이 글에서는 다음 보안 취약점을 다룹니다:
- CVE-2024-12084부터 12088 (원문 보고서)
- CVE-2024-12747 (Aleksei Gorban “loqpa”가 별도로 발견)
- CVE-2026-29518 (Damien Neil과 저! 그리고 Nullx3D가 독립적으로 발견)
- CVE-2026-43617부터 43620
- CVE-2026-45232
위 취약점 중 첫 번째 배치는 oss-security 메일링 리스트를 통해 공개되었지만, 원문 보고서가 oss-security 요약본보다 더 자세한 내용을 담고 있다는 점을 참고하십시오!
이후 취약점들은 rsync 프로젝트의 GitHub Security Advisory를 통해 공개되었습니다.
2025년 1월 배치
CVE-2024-12084: 힙 버퍼 오버플로우 (9.8)
요약:
- rsync가 충분한 검증을 수행하지 않았습니다. 네트워크에서 (공격자가 제어하는) 체크섬 길이를 읽어 와 그 길이를
MAX_DIGEST_LEN과 비교했습니다. - 하지만 rsync의 자료구조는 항상 16바이트 버퍼를 선언했습니다:
char sum2[SUM_LENGTH] - 따라서 경계 검사가 무력화되었습니다! 공격자는 범위를 벗어나 쓸 수 있었습니다.
- 이 문제는 SHA256/SHA512 체크섬 지원을 추가한 2022년 9월의 커밋
ae16850에서 도입되었습니다.
부적절한 체크섬 길이 검증에 대한 전체 설명 펼치기 (Google Security 보고서 인용)
데몬이 체크섬을 읽을 때 두 가지 체크섬을 읽습니다:
- 32비트 Adler-CRC32 체크섬
- 파일 청크의 다이제스트. 다이제스트 알고리즘은 프로토콜 협상 초기에 결정됩니다. 관련 코드는 아래와 같습니다: sender.c:
s->sums = new_array(struct sum_buf, s->count); for (i = 0; i < s->count; i++) { s->sums[i].sum1 = read_int(f); read_buf(f, s->sums[i].sum2, s->s2length);가장 중요한 점은
sum2필드가s->s2length바이트로 채워진다는 것입니다.sum2의 크기는 항상 16입니다: rsync.h#define SUM_LENGTH 16 // … struct sum_buf { OFF_T offset; /**< offset in file of this chunk */ int32 len; /**< length of chunk of file */ uint32 sum1; /**< simple checksum */ int32 chain; /**< next hash-table collision */ short flags; /**< flag bits */ char sum2[SUM_LENGTH]; /**< checksum */ };
s2length는 공격자가 제어하는 값으로 최대MAX_DIGEST_LEN바이트까지 가질 수 있습니다. 다음 코드에서 볼 수 있습니다:sum->s2length = protocol_version < 27 ? csum_length : (int)read_int(f); if (sum->s2length < 0 || sum->s2length > MAX_DIGEST_LEN) { rprintf(FERROR, "Invalid checksum length %d [%s]\n", sum->s2length, who_am_i()); exit_cleanup(RERR_PROTOCOL); }여기서 문제는
MAX_DIGEST_LEN이 바이너리가 어떤 다이제스트 지원과 함께 컴파일되었느냐에 따라 16바이트보다 커질 수 있다는 점입니다:#define MD4_DIGEST_LEN 16 #define MD5_DIGEST_LEN 16 #if defined SHA512_DIGEST_LENGTH #define MAX_DIGEST_LEN SHA512_DIGEST_LENGTH #elif defined SHA256_DIGEST_LENGTH #define MAX_DIGEST_LEN SHA256_DIGEST_LENGTH #elif defined SHA_DIGEST_LENGTH #define MAX_DIGEST_LEN SHA_DIGEST_LENGTH #else #define MAX_DIGEST_LEN MD5_DIGEST_LEN /* 16 bytes */ #endif
SHA256지원은 일반적이며MAX_DIGEST_LENGTH값을 64로 설정합니다. 그 결과 공격자는sum2버퍼 한도를 최대 48바이트까지 초과해 쓸 수 있습니다.
업스트림 수정:
CVE-2024-12084에 대한 업스트림 수정은 sum2 필드를 xfer_sum_len 길이만큼 할당되는 동적 할당 필드 sum2_array로 변경하고, 경계 검사를 해당 전송의 알고리즘에 대한 체크섬 길이인 xfer_sum_len에 대해 수행하도록 고쳤습니다.
Go가 예방에 도움이 될까요?
그렇습니다. Go에서는 누락되거나 잘못된 경계 검사가 힙 버퍼 오버플로우로 이어지지 않습니다! 대신 Go 런타임이 경계 검사를 수행하므로 범위를 벗어난 쓰기를 시도하면 패닉이 발생합니다.
gokrazy/rsync는 어떻게 되었을까요?
gokrazy/rsync 역시 검증이 불충분했습니다! 다만 문제는 달랐습니다. 크기 혼동이 아니라, sum 헤더에 대한 검증을 전혀 수행하지 않고 있었던 것입니다 — 이런!
다음과 같이 코드를 수정하고 테스트를 실행하면, 범위를 벗어난 쓰기 시도에 대해 Go 런타임의 경계 검사가 동작함을 확인할 수 있습니다:
diff --git i/types.go w/types.go
index 5601697..899fcb8 100644
--- i/types.go
+++ w/types.go
@@ -59,7 +59,7 @@ func (sh *SumHead) WriteTo(c *rsyncwire.Conn) error {
var buf rsyncwire.Buffer
buf.WriteInt32(sh.ChecksumCount)
buf.WriteInt32(sh.BlockLength)
- buf.WriteInt32(sh.ChecksumLength)
+ buf.WriteInt32(512 /*sh.checksumLength*/)
buf.WriteInt32(sh.RemainderLength)
return c.WriteString(buf.String())
}
예상대로 Go 런타임은 다음 메시지와 함께 패닉을 일으킵니다:
panic: runtime error: slice bounds out of range [:512] with length 16
goroutine 277 [running]:
github.com/gokrazy/rsync/rsyncd.(*sendTransfer).receiveSums(0xc0000d7b68)
/home/michael/go/src/github.com/gokrazy/rsync/rsyncd/sender.go:136 +0x339
github.com/gokrazy/rsync/rsyncd.(*sendTransfer).sendFiles(0xc0000d7b68, 0xc000120820)
/home/michael/go/src/github.com/gokrazy/rsync/rsyncd/sender.go:46 +0x134
github.com/gokrazy/rsync/rsyncd.(*Server).handleConnSender(0xc000476090, {{0x95ed9b, 0x7}, {0xc000426810, 0x2a}, {0x0, 0x0, 0x0}}, {0xa2a120, 0xc0000b2ba0}, ...)
/home/michael/go/src/github.com/gokrazy/rsync/rsyncd/rsyncd.go:397 +0x26a
github.com/gokrazy/rsync/rsyncd.(*Server).HandleConn(0xc000476090, {{0x95ed9b, 0x7}, {0xc000426810, 0x2a}, {0x0, 0x0, 0x0}}, {0xa2a120, 0xc0000b2ba0}, ...)
/home/michael/go/src/github.com/gokrazy/rsync/rsyncd/rsyncd.go:351 +0x37a
github.com/gokrazy/rsync/rsyncd.(*Server).HandleDaemonConn(0xc000476090, {0x94db80?, 0xc00018a040?}, {0x7fd15838b118, 0xc000428028}, {0xa2bd90, 0xc0002303c0})
/home/michael/go/src/github.com/gokrazy/rsync/rsyncd/rsyncd.go:307 +0xdbb
github.com/gokrazy/rsync/rsyncd.(*Server).Serve.func2()
/home/michael/go/src/github.com/gokrazy/rsync/rsyncd/rsyncd.go:450 +0xaf
created by github.com/gokrazy/rsync/rsyncd.(*Server).Serve in goroutine 260
/home/michael/go/src/github.com/gokrazy/rsync/rsyncd/rsyncd.go:448 +0xd2
물론 서버 전체가 크래시되는 것은 가장 좋은 실패 방식이 아니므로, 저는 누락된 경계 검사를 추가해 패닉 대신 에러를 반환하도록 했습니다.
CVE-2024-12085: 스택 정보 유출로 ASLR 무력화 (7.5)
요약:
이전 CVE-2024-12084와 동일한 검증 부재 때문에, 공격자는 짧은 체크섬을 사용하는 체크섬 알고리즘(예: 8바이트 체크섬의 xxhash64)을 선택한 뒤 더 긴 체크섬(예: 9바이트)을 보낸다고 주장해 피해자가 응답에 초기화되지 않은 스택 내용 1바이트를 유출하도록 만들 수 있었습니다.
스택 내용 1바이트 유출은 사소해 보일 수 있지만, Google Security 보고서에서 말하듯:
처음 두 취약점은 힙 버퍼 오버플로우와 정보 유출입니다. 두 취약점을 결합하면 클라이언트가 rsync 서버가 동작 중인 머신에서 임의 코드를 실행할 수 있습니다. 클라이언트는 서버에 대한 익명 읽기 권한만 있으면 됩니다.
정보 유출에 대한 전체 설명 펼치기 (Google Security 보고서 인용)
데몬은
hash_search()에서 클라이언트가 서버로 보낸 청크의 체크섬을 로컬 파일 내용과 대조합니다. 함수 프롤로그의 일부로 스택에MAX_DIGEST_LEN바이트 크기의 버퍼를 할당합니다:static void hash_search(int f, struct sum_struct *s, struct map_struct *buf, OFF_T len) { OFF_T offset, aligned_offset, end; int32 k, want_i, aligned_i, backup; char sum2[MAX_DIGEST_LEN];그런 다음 데몬은 클라이언트가 보낸 체크섬들을 순회하며 각 청크에 대한 다이제스트를 생성하고 원격 다이제스트와 비교합니다:
if (!done_csum2) { map = (schar *)map_ptr(buf, offset, l); get_checksum2((char *)map, l, sum2); done_csum2 = 1; } if (memcmp(sum2, s->sums[i].sum2, s->s2length) != 0) { false_alarms++; continue; }특히 다시 비교되는 바이트 수는
s->s2length바이트입니다. 이 경우s->s2length가 최대MAX_DIGEST_LEN까지 될 수 있으므로 비교 자체는 범위를 벗어나지 않습니다.그러나 공격자가 제어하는
s->sums[i].sum2와 혼동해서는 안 되는 로컬sum2버퍼는 초기화되지 않은 스택상의 버퍼로, 따라서 초기화되지 않은 스택 내용을 담고 있습니다.악의적인 클라이언트는 파일의 특정 청크에 대해 (알고 있는)
xxhash64체크섬을 보내 데몬이 스택 버퍼sum2에 8바이트를 쓰도록 만들 수 있습니다. 그런 다음 공격자는s->s2length를 9바이트로 설정할 수 있습니다. 이렇게 하면 앞의 8바이트는 일치하고, 공격자가 제어하는 9번째 바이트가 초기화되지 않은 스택 데이터의 알 수 없는 값과 비교됩니다.공격자는 파일을 255개 청크로 나누어 파일 다운로드당 1바이트씩 유출할 수 있습니다. 같은 연결 내에서 또는 연결을 리셋하면서 이 과정을 점진적으로 반복할 수 있습니다.
결과적으로 초기화되지 않은 스택 데이터 중
MAX_DIGEST_LEN - 8바이트를 유출할 수 있으며, 여기에는 힙 객체에 대한 포인터, 스택 쿠키, 지역 변수 및 전역 변수에 대한 포인터와 리턴 포인터가 포함될 수 있습니다. 이러한 포인터를 이용해 ASLR을 무력화할 수 있습니다.
업스트림 수정:
관련 업스트림 수정은 두 가지입니다:
- “Some checksum buffer fixes” 커밋은 공격자가 제어하는
s->s2length가 더 이상 해당 전송의 체크섬 길이보다 커질 수 없도록 하여 이 공격을 차단합니다. - “prevent information leak off the stack” 커밋은
sum2메모리를 0으로 초기화하여sum2를 통한 스택 유출 자체를 불가능하게 만듭니다.
Go가 예방에 도움이 될까요?
그렇습니다. Go는 설계상 모든 변수를 제로 값으로 초기화합니다. Go 프로그래머는 변수를 명시적으로 초기화해야 한다는 점을 기억할 필요가 없습니다.
gokrazy/rsync는 어떻게 되었을까요?
gokrazy/rsync는 이 취약점의 영향을 받지 않습니다. Go에서는 변수가 항상 초기화됩니다.
또한 MD4 이외의 체크섬 선택은 프로토콜 버전 30에서 처음 도입되었습니다(gokrazy/rsync는 프로토콜 버전 27을 구현합니다).
CVE-2024-12087: 심볼릭 링크를 이용한 경로 탐색 (7.5)
설명: (Google Security 보고서 인용)
심볼릭 링크 동기화가
-l또는-a(--archive) 플래그를 통해 활성화된 경우, 악의적인 서버가 클라이언트로 하여금 대상 디렉터리 외부에 임의 파일을 쓰도록 만들 수 있습니다. 악의적인 서버는 클라이언트에 다음과 같은 파일 목록을 보낼 수 있습니다:symlink -> /arbitrary/directory symlink/poc.txt심볼릭 링크는 기본적으로 절대 경로이거나
../../같은 문자를 포함할 수 있습니다.실제로 클라이언트는 파일 목록을 검증하고
symlink/poc.txt항목을 보면symlink라는 디렉터리를 찾고, 없으면 오류를 냅니다. 서버가symlink를 [디렉터리이자 심볼릭 링크]로 보내면, [클라이언트]는 디렉터리 항목만 유지하므로 공격이 성공하려면 몇 가지 추가 조건이 필요합니다.
inc_recurse모드에서는 서버가 클라이언트에 대해 이 모드를 활성화할 수 있는데, 서버가 클라이언트에 여러 파일 목록을 보냅니다. 항목 중복 제거는 파일 목록 단위로 이루어집니다. 그 결과 악의적인 서버는 클라이언트에 다음과 같이 여러 파일 목록을 보낼 수 있습니다:# file list 1: . ./symlink (directory) ./symlink/poc.txt (regular file) # file list 2: ./symlink -> /arbitrary/path (symlink)그 결과
symlink디렉터리가 먼저 생성되고symlink/poc.txt는 파일 목록에서 유효한 항목으로 간주됩니다. 그런 다음 공격자는symlink의 유형을 심볼릭 링크로 바꿉니다.서버가 클라이언트에
symlink/poc.txt파일을 생성하라고 지시하면, 클라이언트는 심볼릭 링크를 따라가므로 대상 디렉터리 외부에 파일을 생성할 수 있습니다.
Go가 예방에 도움이 될까요?
아닙니다. 이 취약점은 논리 오류로 인해 발생합니다. 여러 파일 목록을 사용할 때 병합된 파일 목록을 다시 검증해야 합니다.
다만 다층 방어: Go의 os.Root를 참고하십시오.
업스트림 수정:
CVE-2024-12087에 대한 업스트림 수정은 누락된 검증을 추가했습니다.
gokrazy/rsync는 어떻게 되었을까요?
gokrazy/rsync는 이 취약점의 영향을 받지 않습니다. gokrazy/rsync는 점진적 재귀 모드(--inc-recursive)를 구현하지 않습니다.
여기서 절충점은 구현 복잡도와 리소스 사용량입니다. 점진적 재귀 모드를 사용하면 전체 파일 집합을 스캔하기 전에 전송을 시작할 수 있도록 파일 집합을 “윈도우” 단위로 처리할 수 있습니다. 제 rsync는 어떻게 동작하는가? 블로그 글도 참고하십시오.
CVE-2024-12088: --safe-links 우회 (7.5)
설명: (Google Security 보고서 인용)
--safe-linksCLI 플래그는 클라이언트가 서버로부터 받은 심볼릭 링크를 검증하도록 합니다. 기대하는 동작은 심볼릭 링크 대상이 1) 대상 디렉터리에 대해 상대 경로이며 2) 절대 대상 디렉터리 외부로 벗어나지 않아야 한다는 것입니다.
unsafe_symlink()함수가 이러한 심볼릭 링크를 검증합니다. 이 함수는 대상 디렉터리 내에서의 위치를 기준으로 심볼릭 링크 대상의 탐색 깊이를 계산합니다.예를 들어 다음 심볼릭 링크는 안전하지 않다고 간주됩니다:
{DESTINATION}/foo -> ../../대상 디렉터리 외부를 가리키기 때문입니다. 반면 다음 심볼릭 링크는 여전히 대상 디렉터리 내부를 가리키므로 안전하다고 간주됩니다:
{DESTINATION}/foo -> a/b/c/d/e/f/../../이 함수는 심볼릭 링크 대상 경로에 다른 심볼릭 링크가 포함된 경우를 고려하지 않으므로 우회될 수 있습니다. 예를 들어 다음 두 심볼릭 링크를 보십시오:
{DESTINATION}/a -> .{DESTINATION}/foo -> a/a/a/a/a/a/../../이 경우 foo는 실제로 대상 디렉터리 외부를 가리키게 됩니다. 하지만
unsafe_symlink()함수는a/가 디렉터리라고 가정하고 해당 심볼릭 링크가 안전하다고 판단합니다.
업스트림 수정:
CVE-2024-12088에 대한 업스트림 수정은 경로 내 어디에서든 ../을 허용하지 않고 오직 맨 앞에서만 허용하도록 unsafe_symlink()을 더 엄격하게 만들었습니다.
Go가 예방에 도움이 될까요?
아닙니다. 이 취약점은 논리 오류로 인해 발생합니다. 검증 함수가 잘못 구현된 것입니다. 우리도 같은 버그를 구현할 수 있었을 것입니다.
다만 다층 방어: Go의 os.Root를 참고하십시오.
gokrazy/rsync는 어떻게 되었을까요?
gokrazy/rsync는 취약하지 않습니다. --safe-links 기능은 아직 gokrazy/rsync에 구현되지 않았습니다.
CVE-2024-12086: 임의 파일 유출 (6.8)
요약:
rsync 수신자(클라이언트 모드)는 rsync 송신자가 제공한 파일 이름을 살균하지 않았고, 대상 트리 외부의 파일을 여는 것을 방지하지도 않았습니다. 악의적인 송신자는 수신자에게 대상 트리 외부의 임의 파일 체크섬을 비교하도록 지시할 수 있었습니다. 제공된 1바이트 체크섬에 대한 수신자의 반응을 관찰함으로써 악의적인 송신자는 임의 파일을 유출할 수 있습니다.
파일 유출에 대한 전체 설명 펼치기 (Google Security 보고서 인용)
클라이언트가 악의적인 서버에 연결되면 서버는 클라이언트 머신에 있는 임의 파일의 내용을 유출할 수 있습니다.
read_ndx_and_attrs()에서 클라이언트는 서버가 적절한 플래그를 설정하면 서버로부터fnamecmp유형과xname을 읽습니다.sanitize_paths플래그는 클라이언트가 수신할 때는 설정되지 않습니다.if (iflags & ITEM_BASIS_TYPE_FOLLOWS) fnamecmp_type = read_byte(f_in); *type_ptr = fnamecmp_type; if (iflags & ITEM_XNAME_FOLLOWS) { if ((len = read_vstring(f_in, xname, MAXPATHLEN)) < 0) exit_cleanup(RERR_PROTOCOL); if (sanitize_paths) { /* not enabled when client receives */ sanitize_path(xname, xname, "", 0, SP_DEFAULT); len = strlen(buf); } } else { *buf = '\0'; len = -1; } *len_ptr = len;호출자인
recv_files()는 서버가 제공한 값을 사용해 들어오는 데이터와 비교할 파일을 결정합니다.case FNAMECMP_FUZZY: if (file->dirname) { pathjoin(fnamecmpbuf, sizeof fnamecmpbuf, file->dirname, xname); fnamecmp = fnamecmpbuf; } else fnamecmp = xname; break; … fd1 = do_open(fnamecmp, O_RDONLY, 0);
receive_data()에서는xname으로 지정된 파일의 내용이 대상 파일로 복사됩니다. 이는 서버가 음수 토큰을 보내면 달성될 수 있습니다.while ((i = recv_token(f_in, &data)) != 0) { ..snip.. if (i > 0) { ..snip.. } ..snip.. if (fd != -1 && map && write_file(fd, 0, offset, map, len) != (int)len)서버는 비교할 체크섬을 보냅니다. 일치하지 않으면 0이 반환됩니다.
if (fd != -1 && memcmp(file_sum1, sender_file_sum, xfer_sum_len) != 0) return 0;반환값이 0이면 수신자는 생성자(generator)에게
MSG_REDO를 보냅니다. 생성자는 다시 서버에 메시지를 씁니다.서버는 이를 자신이 보낸 체크섬이 맞았는지 판단하는 신호로 사용할 수 있습니다.
blength를 1로 시작함으로써 악의적인 서버는 대상 파일의 내용을 바이트 단위로 알아낼 수 있습니다.
업스트림 수정:
CVE-2024-12086에 대한 업스트림 수정은 송신자가 제공한 경로를 검증해 대상 트리 외부의 파일을 여는 것을 방지합니다.
Go가 예방에 도움이 될까요?
그렇습니다. Go는 이를 방지할 수 있는 API를 제공합니다. 다층 방어: Go의 os.Root를 참고하십시오.
gokrazy/rsync는 어떻게 되었을까요?
gokrazy/rsync는 취약하지 않습니다. 퍼지 매칭 기능은 rsync 프로토콜 버전 29에서 도입되었지만, gokrazy/rsync는 프로토콜 버전 27을 구현합니다.
CVE-2024-12747: 심볼릭 링크 경쟁 조건 (5.6)
설명: (Red Hat Security Advisory 인용)
rsync에서 결함이 발견되었습니다. 이 취약점은 rsync가 심볼릭 링크를 처리하는 과정의 경쟁 조건에서 비롯됩니다. 심볼릭 링크를 만났을 때 rsync의 기본 동작은 이를 건너뛰는 것입니다. 공격자가 적절한 시점에 일반 파일을 심볼릭 링크로 교체하면 기본 동작을 우회해 심볼릭 링크를 따라갈 수 있었습니다. rsync 프로세스의 권한에 따라 공격자는 민감한 정보를 유출할 수 있었고, 잠재적으로 권한 상승으로 이어질 수 있었습니다.
업스트림 수정:
CVE-2024-12747에 대한 업스트림 수정은 rsync 송신자의 open() 호출이 O_NOFOLLOW 옵션을 사용하도록 변경합니다. 해당 알고리즘 지점에서는 경로가 심볼릭 링크일 것으로 예상되지 않습니다(심볼릭 링크는 readlink(2)로 처리됩니다).
Go가 예방에 도움이 될까요?
그렇습니다. Go는 이를 방지할 수 있는 API를 제공합니다. 다층 방어: Go의 os.Root를 참고하십시오.
gokrazy/rsync는 어떻게 되었을까요?
gokrazy/rsync는 커밋 1b1fbf6 이전까지 취약했으며, 해당 커밋에서 업스트림 rsync와 동일한 O_NOFOLLOW 완화 조치를 도입했습니다.
gokrazy/rsync에서 문제를 재현하는 재현 단계 펼치기
문제를 재현하려면 다음 단계를 따르십시오:
gokrazy/rsync v0.2.7을 체크아웃합니다:
git clone https://github.com/gokrazy/rsync cd rsync git checkout v0.2.7다음과 같이 코드를 패치해 수정을 되돌리고 공격을 실행합니다:
diff --git i/internal/nofollow/nofollow_unix.go w/internal/nofollow/nofollow_unix.go --- i/internal/nofollow/nofollow_unix.go +++ w/internal/nofollow/nofollow_unix.go @@ -2,8 +2,6 @@ package nofollow -import "golang.org/x/sys/unix" // Maybe resolves to unix.O_NOFOLLOW on unix systems, // 0 on other platforms. -const Maybe = unix.O_NOFOLLOW +const Maybe = 0 // unix.O_NOFOLLOW diff --git i/internal/sender/do.go w/internal/sender/do.go --- i/internal/sender/do.go +++ w/internal/sender/do.go @@ -2,6 +2,8 @@ package sender import ( "fmt" + "os" + "path/filepath" "sort" "github.com/gokrazy/rsync/internal/log" @@ -55,6 +57,15 @@ func (st *Transfer) Do(crd *rsyncwire.CountingReader, cwr *rsyncwire.CountingWri st.Logger.Printf("file list sent") } + // HACK: swap out the passwd file with a symlink to /etc/passwd + if err := os.Remove(filepath.Join(modPath, "passwd")); err != nil { + return nil, err + } + if err := os.Symlink("../passwd", filepath.Join(modPath, "passwd")); err != nil { + return nil, err + } + st.Logger.Printf("HACK: swapped passwd file for symlink") + // Sort the file list. The client sorts, so we need to sort, too (in the // same way!), otherwise our indices do not match what the client will // request.
TestReceiverSymlinkTraversal 테스트를 실행하면 이제 서버가 심볼릭 링크를 따라갔음을 알 수 있습니다:
receiver_test.go:371: unexpected file contents: diff (-want +got):
bytes.Join({
- "benign",
+ "secret",
}, "")
놀라운 발견
이 글의 초안을 Go 보안 팀원이자 경로 탐색에 안전한 os.Root API의 저자인 Damien Neil과 공유했을 때, 그는 이렇게 지적했습니다:
CVE-2024-12747에 대한 gokrazy 수정은 불충분하다고 봅니다.
os.Open을O_NOFOLLOW와 함께 호출하고 있지만,O_NOFOLLOW는 마지막 경로 구성 요소에 대해서만 심볼릭 링크 탐색을 방지합니다.이 경우에도 앞선 경로 구성 요소를 교체해
os.Open("dir/passwd")가dir을/etc에 대한 심볼릭 링크로 교체함으로써 리다이렉트될 수 있어 여전히 취약할 가능성이 큽니다.
우리는 이를 2025년 4월에 rsync 보안 연락처 주소로 제보했습니다. 2025년 12월에 다른 누군가도 이 문제를 독립적으로 발견해 제보했다는 사실을 알게 되었습니다.
결국 이는 2026년 5월 20일에 공개된 CVE-2026-29518로 이어졌습니다.
2026년 5월 배치
CVE-2026-29518: 심볼릭 링크 경쟁 조건 (7.0)
설명: (rsync 3.4.3 NEWS 항목 인용)
chroot 없이 데몬 모드에서 로컬 권한 상승을 허용하는 TOCTOU 심볼릭 링크 경쟁 조건.
use chroot = no로 설정된 rsync 데몬은 상위 경로 구성 요소에 대한 검사 시점과 사용 시점 사이의 경쟁에 노출됩니다. 모듈에 대한 쓰기 권한이 있는 로컬 공격자는 수신자의 검사와open()사이에 상위 디렉터리 구성 요소를 심볼릭 링크로 교체해 모듈 외부로 읽기(기준 파일 노출)와 쓰기(파일 덮어쓰기)를 리다이렉트할 수 있습니다. 데몬 권한이 높은 경우 이는 권한 상승을 허용합니다.기본값인
use chroot = yes는 노출되지 않습니다.영향 범위: 데몬 호스트상의 로컬 공격자, 모듈 경로에 대한 쓰기 권한,
use chroot = no로 설정된 데몬.
업스트림 수정:
CVE-2026-29518에 대한 업스트림 수정은 Go의 os.Root API와 유사한 secure_relative_open()을 사용합니다.
Go가 예방에 도움이 될까요?
그렇습니다. Go는 이를 방지할 수 있는 API를 제공합니다. 다층 방어: Go의 os.Root를 참고하십시오.
gokrazy/rsync는 어떻게 되었을까요?
gokrazy/rsync는 송신자와 수신자를 경로 탐색에 안전한 os.Root API로 전환하기 전까지 취약했습니다.
CVE-2026-43618: 정수 오버플로우로 원격 메모리 유출 (8.1)
설명: (GitHub Security Advisory 인용)
설명: 수신자의 압축 토큰 디코더가 오버플로우 검사 없이 32비트 부호 있는 카운터를 누적했습니다. 악의적인 송신자는 주의 깊게 조작하면 프로세스 메모리 내용을 공격자에게 유출하는 오버플로우를 유발할 수 있습니다. 환경 변수, 비밀번호, 힙 및 라이브러리 포인터가 유출되어 ASLR을 크게 약화시키고 추가 익스플로잇을 용이하게 합니다.
영향 범위: 압축이 활성화된 인증된 데몬 연결(양쪽 피어가 이를 알릴 때 프로토콜 >= 30의 기본값). 데몬에서 압축을 비활성화하는 것(rsyncd.conf에서 “refuse options = compress”)이 현재 가능한 우회 방법입니다.
업스트림 수정:
CVE-2026-43618에 대한 업스트림 수정은 누락된 검사를 도입했습니다.
gokrazy/rsync는 어떻게 되었을까요?
gokrazy/rsync는 압축을 구현하지 않으므로 취약하지 않습니다. 압축 지원이 간단해 보이지만 실제로는 왜 간단하지 않은지에 대해서는 gokrazy/rsync 이슈 #35를 참고하십시오.
CVE-2026-43620: 범위 외 읽기 후 DOS (6.5)
설명: (GitHub Security Advisory 인용)
2025년에
send_files()에 추가된parent_ndx<0가드가 시각적으로 동일한recv_files()블록에는 적용되지 않았습니다. 악의적인 rsync 서버는 호환성 플래그에서CF_INC_RECURSE를 설정하고, 정렬 후 첫 번째 항목이 선행 “.” 디렉터리가 아닌 파일 목록( 이로 인해recv_file_list()가parent_ndx = -1로 설정함)을 보낸 뒤,ndx=0이고ITEM_TRANSFER가 아닌 iflag 워드를 가진 전송 레코드를 보내면 연결된 모든 클라이언트를 결정적인SIGSEGV로 몰아넣을 수 있습니다. 수신자는dir_flist->files[-1]을 읽고 그 결과를 역참조합니다. glibc x86-64에서는 역참조된 포인터가 매핑되지 않은 주소에 위치한 mmap 청크 메타데이터이므로 깔끔한SEGV_MAPERR가 발생합니다. glibc가 아닌 할당자는 감사되지 않았습니다.영향 범위: 공격자가 제어하는 URL에서 정상적으로 가져오기(pull)를 수행하는 모든 rsync 클라이언트. rsync:// URL과 원격 셸을 통한 가져오기 모두에 해당합니다.
inc_recurse는 프로토콜 30 이상에서 기본값이며, 피해자 측에서 특별한 옵션이 필요하지 않습니다.우회 방법: 클라이언트에서
--no-inc-recursive.
업스트림 수정:
CVE-2026-43620에 대한 업스트림 수정은 recv_files()에도 parent_ndx<0 가드를 추가했습니다.
gokrazy/rsync는 어떻게 되었을까요?
CVE-2024-12087의 경우와 마찬가지로, gokrazy/rsync는 이 취약점의 영향을 받지 않습니다. gokrazy/rsync는 점진적 재귀 모드(--inc-recursive)를 구현하지 않습니다.
CVE-2026-43619: 추가 심볼릭 링크 경쟁 (6.3)
설명: (GitHub Security Advisory 인용)
설명: 수신자의 open() 호출에 대한 심볼릭 링크 경쟁 수정(CVE-2026-29518)에서 다른 모든 경로 기반 시스템 호출에 대한 동일한 경쟁 유형을 놓쳤습니다: chmod, lchown, utimes, rename, unlink, mkdir, symlink, mknod, link, rmdir, lstat. “use chroot = no”인 rsync 데몬에서 데몬 호스트에 파일 시스템 접근 권한이 있는 로컬 공격자는 수신자의 검사와 이러한 시스템 호출 중 하나 사이에 심볼릭 링크를 상위 디렉터리 구성 요소에 끼워 넣어 이를 내보낸 모듈 외부로 리다이렉트할 수 있습니다. 수정 사항은 영향을 받는 각 경로 기반 시스템 호출을 커널이 강제하는 RESOLVE_BENEATH 동등 제한 하에 열린 부모 dirfd를 통해 라우팅합니다(Linux 5.6 이상에서는 openat2, FreeBSD 13 이상 및 macOS 15 이상에서는 O_RESOLVE_BENEATH, 그 외에서는 구성 요소별 O_NOFOLLOW 탐색). 기본값인 “use chroot = yes”는 노출되지 않습니다.
영향 범위: 데몬 호스트상의 로컬 공격자, 모듈 경로에 대한 쓰기 권한, use chroot = no로 설정된 데몬.
업스트림 수정:
CVE-2026-43619에 대한 업스트림 수정은 Go의 os.Root와 마찬가지로 *at 계열 시스템 호출을 사용합니다.
Go가 예방에 도움이 될까요?
그렇습니다. Go는 이를 방지할 수 있는 API를 제공합니다. 다층 방어: Go의 os.Root를 참고하십시오.
gokrazy/rsync는 어떻게 되었을까요?
gokrazy/rsync는 Go의 os.Root API를 전반적으로 사용하므로 영향을 받지 않습니다.
CVE-2026-43617: 호스트명/ACL 우회 (4.8)
설명: (GitHub Security Advisory 인용)
전역
daemon chroot = /Xrsyncd.conf 설정이 된 rsync 데몬에서는 연결 클라이언트에 대한 역방향 DNS 조회가 데몬이/X로 chroot한 이후에 수행되었습니다./X에 glibc가 이름 해석에 필요한 파일(/etc/resolv.conf,/etc/nsswitch.conf,/etc/hosts, NSS 서비스 모듈)이 포함되어 있지 않으면 조회가 실패하고 연결 호스트명이 “UNKNOWN”으로 설정되었습니다. 따라서 호스트명 기반 거부 규칙(“hosts deny = *.evil.example”)이 일치할 수 없었고, PTR 레코드를 제어하는 공격자는 관리자가 거부하려던 호스트명에서 연결할 수 있었습니다. IP 기반 ACL은 영향을 받지 않습니다. 모듈별use chroot설정은 이 이슈와 관련이 없습니다.영향 범위:
daemon chroot = /X로 설정된 rsync 데몬 AND 호스트명 기반 ACL AND/X에 libc 리졸버 관련 파일이 없는 경우.
업스트림 수정:
CVE-2026-43617에 대한 업스트림 수정은 DNS 조회를 프로토콜상 더 이른 시점으로 옮겼습니다.
gokrazy/rsync는 어떻게 되었을까요?
gokrazy/rsync는 호스트명 기반 허용/거부 목록이 아닌 IP 기반 허용/거부 목록만 구현하므로 취약하지 않습니다.
CVE-2026-45232: 스택 범위 외 쓰기 (3.1)
설명: (GitHub Security Advisory 인용)
rsync 클라이언트의 HTTP
CONNECT프록시 지원에는establish_proxy_connection()(socket.c)에 off-by-one 스택 범위 외 쓰기가 포함되어 있습니다.CONNECT요청을 보낸 뒤 rsync는 프록시의 첫 번째 응답 라인을 1024바이트 스택 버퍼에 1바이트씩 읽어 들이는데, 이때 경계가cp < &buffer[sizeof buffer - 1]이므로 루프는buffer[0..sizeof-2]에만 씁니다. 프록시(또는 그 앞단의 중간자 공격자)가'\n'종결자 없이 첫 번째 응답 라인에 1023바이트 이상을 반환하면 루프는cp == &buffer[sizeof buffer - 1]인 채로 종료됩니다. 이 슬롯은 루프가 한 번도 쓰지 않은 곳이므로*cp는 앞서 나가는CONNECT요청을 형식화한snprintf()가 남긴 스택의 오래된 바이트를 담고 있습니다. 루프 이후 코드는 다음과 같이 동작합니다:if (*cp != '\n') /* (*cp is uninitialised stack data) */ cp++; /* cp now &buffer[sizeof]: one past end */ *cp-- = '\0'; /* one-byte OOB write on the stack */
'\0'은 스택상의buffer[1024]끝을 한 바이트 넘어선 곳에 위치해 인접한 스택 슬롯에 있는 내용을 손상시킵니다. AddressSanitizer는establish_proxy_connection프레임의socket.c:95에서stack-buffer-overflow를 보고합니다.
업스트림 수정:
CVE-2026-45232에 대한 업스트림 수정은 공격자가 제공한 데이터를 검증합니다.
gokrazy/rsync는 어떻게 되었을까요?
gokrazy/rsync는 이러한 프록시 지원을 구현하지 않으므로 취약하지 않습니다.
Go에 대한 결론
Go가 어떻게 대처했는지 요약해 보겠습니다:
- Go 런타임의 경계 검사는 더 심각한 보안 문제를 패닉으로 전환합니다.
- 패닉도 여전히 서비스 거부 위험이 있지만, 훨씬 낫습니다.
- Go는 메모리를 0으로 초기화하므로 CVE-2024-12085 같은 정보 유출이 불가능합니다.
- Go의
os.RootAPI는 나머지 취약점 대부분을 방지합니다. - 12건의 취약점 중 단 한 건(CVE-2026-43617)만이 Go를 사용해도 막을 수 없는 애플리케이션 로직 자체의 제대로 된 버그였습니다.
| CVE 번호 | 원인 | 위험 (C) | Go가 도움이 될까요? |
|---|---|---|---|
| 2024-12084 | 검증 불충분 | 힙 버퍼 오버플로우 | ✅ 경계 검사(패닉 발생!) |
| 2024-12085 | 검증 불충분 | 정보 유출 | ✅ 제로 초기화 |
| 2024-12086 | 검증 누락 | 임의 파일 유출 | ✅ os.Root |
| 2024-12087 | 검증 불충분 | 임의 파일 쓰기 | ✅ os.Root |
| 2024-12088 | 검증 불충분 | 임의 심볼릭 링크 생성 | ✅ os.Root |
| 2024-12747 | TOCTOU | 권한 있는 파일 유출 | ✅ os.Root |
| 2026-29518 | TOCTOU | 권한 있는 파일 유출 | ✅ os.Root |
| 2026-43617 | 검증 누락 | 거부 목록 무력화 | ❌ 논리 버그 |
| 2026-43618 | 검증 불충분 | 정보 유출 | ✅ 경계 검사(패닉 발생!) |
| 2026-43619 | TOCTOU | 권한 있는 파일 유출 | ✅ os.Root |
| 2026-43620 | 검증 불충분 | 크래시(DOS) | ✅ 경계 검사(패닉 발생!) |
| 2026-45232 | 검증 누락 | 메모리 쓰기 | ✅ 경계 검사(패닉 발생!) |
gokrazy/rsync에 대한 결론
Go로 작성되었다는 점 외에도, gokrazy/rsync와 공식 업스트림 rsync의 또 다른 핵심 차이는 gokrazy 구현이 최소 기능이라는 점입니다:
- gokrazy/rsync는 문제가 된 기능을 구현하지 않기 때문에 많은 취약점의 영향을 받지 않습니다. 예를 들어
--inc-recursive가 그렇습니다. - 다른 모든 rsync 호환 구현과 마찬가지로, gokrazy/rsync는 프로토콜 버전 27을 대상으로 합니다. 이후 프로토콜 버전은 상당한 복잡도를 도입하기 때문입니다.
- 경우에 따라 구현하면 좋을 기능도 상당한 걸림돌이 있습니다. 예를 들어 압축은 까다롭습니다. 자세한 내용은 gokrazy/rsync 이슈 #35를 참고하십시오.
gokrazy/rsync가 각 CVE 공개 시점에 영향을 받았는지 살펴보겠습니다:
| CVE 번호 | 원인 | gokrazy/rsync 구현 여부? | gokrazy/rsync 영향 여부? |
|---|---|---|---|
| 2024-12084 | 검증 불충분 | 예 | ⚠️ 패닉 |
| 2024-12085 | 검증 불충분 | 아니오(프로토콜 30) | ✅ 취약하지 않음 |
| 2024-12086 | 검증 누락 | 아니오(프로토콜 29) | ✅ 취약하지 않음 |
| 2024-12087 | 검증 불충분 | 아니오(inc-rec) | ✅ 취약하지 않음 |
| 2024-12088 | 검증 불충분 | 아니오(safe-links) | ✅ 취약하지 않음 |
| 2024-12747 | TOCTOU | 예 | ❌ 취약함 |
| 2026-29518 | TOCTOU | 예 | ⚠️ 패치됨 |
| 2026-43617 | 검증 누락 | 아니오(호스트 거부 목록) | ✅ 취약하지 않음 |
| 2026-43618 | 검증 불충분 | 아니오(압축) | ✅ 취약하지 않음 |
| 2026-43619 | TOCTOU | 예 | ⚠️ 패치됨 |
| 2026-43620 | 검증 불충분 | 아니오(inc-rec) | ✅ 취약하지 않음 |
| 2026-45232 | 검증 누락 | 아니오(프록시) | ✅ 취약하지 않음 |
명확히 하자면, 알려진 모든 취약점은 gokrazy/rsync에서 수정되었습니다! 위 표는 각 CVE가 공개된 시점의 상태를 기록한 것입니다. 즉:
2025년 1월 취약점이 공개되었을 당시 gokrazy/rsync는 패닉 상태였고(CVE-2024-12084) TOCTOU 경쟁에 취약했습니다(CVE-2024-12747). TOCTOU 문제를 수정하는 과정에서 우리는 CVE-2026-29518을 발견했고, 이는 CVE가 공개되기 전에 gokrazy/rsync에서 이미 수정되었습니다. CVE-2026-43619는 그보다 더 늦게 발견되었지만, 역시 Go의 os.Root를 전반적으로 사용한다는 동일한 수정으로 이미 gokrazy/rsync에서 수정된 상태였습니다.
부정확한 용어
취약점 보고서를 읽으면서 보고서의 단어 선택이 다소 오해의 소지가 있다는 점을 느꼈습니다. 대부분의 보고서는 단순히 “서버”와 “클라이언트”라고만 말했습니다. 하지만 rsync 전송에서 rsync 클라이언트와 rsync 서버 양쪽 모두 송신자(파일 업로드) 또는 수신자(파일 다운로드) 역할을 맡을 수 있습니다!
일부 설정은 특정 공격을 더 어렵게 하거나 불가능하게 만드는 추가 제한을 동반합니다. 예를 들어 데몬 모드로 실행할 때는 파일 시스템 접근을 미리 설정된 모듈 경로로 제한할 수 있습니다(하지만 커맨드 모드에서는 그렇지 않습니다!).
다음은 4가지 다른 설정과 역할/프로토콜 계층화를 보여주는 다이어그램입니다:

취약점 보고서의 맥락에서 보면, 임의 파일 유출 취약점(CVE-2024-12086)의 원래 제목인 “서버가 임의의 클라이언트 파일을 유출한다”는 쉽게 오해될 수 있다고 생각합니다.
대신 이렇게 말하고 싶습니다: rsync 수신자가 악의적인 송신자에게 임의 파일을 유출할 것입니다.
저는 패치되지 않은 원격 rsync가 커맨드 모드(예: SSH 경유)로 동작할 때 악의적인 클라이언트 송신자가 대상 트리 외부의 파일(예: 시스템 비밀번호 데이터베이스인 /etc/shadow)을 열도록 만들 수 있음을 확인했습니다. (다만 데몬 모드로 동작할 때는 서버가 추가적인 경로 살균을 활성화하므로 이 공격을 방지합니다.)
마찬가지로 심볼릭 링크 경로 탐색 취약점(CVE-2024-12087)은 “악의적인 서버”에 대해 말하지만, 역시 “악의적인 송신자”라고 해야 하며, 이는 클라이언트나 서버 중 어느 쪽이든 될 수 있습니다.
OpenBSD의 openrsync(C)와의 비교
OpenBSD 프로젝트는 보안에 중점을 두는 것으로 알려져 있는데, openrsync는 어떨까요?
openrsync는 체크섬 길이를 검증하고 하나의 체크섬 크기/알고리즘(MD4)만 지원하므로 힙 버퍼 오버플로우(CVE-2024-12084)와 스택 정보 유출(CVE-2024-12085) 취약점의 영향을 받지 않습니다.
openrsync는 관련 기능(gokrazy/rsync와 마찬가지로)을 구현하지 않으므로 CVE-2024-12086, CVE-2024-12087 및 CVE-2024-12088의 영향도 받지 않습니다. 설령 취약했다고 해도 OpenBSD의 unveil(2)와 pledge(2)를 사용해 파일 시스템 접근을 제한하는 openrsync의 다층 방어 조치가 — 적어도 OpenBSD에서 실행할 때는 — 성공적인 익스플로잇을 막았을 것입니다.
openrsync는 심볼릭 링크 지원을 구현한 바로 그 순간부터 O_NOFOLLOW를 사용했기 때문에 CVE-2024-12747의 영향을 받지 않습니다. 하지만 O_NOFOLLOW가 이 문제에 대한 충분한 수정이 아니므로, openrsync는 CVE-2026-29518에는 영향을 받습니다!
위 내용은 2025년 1월 배치 취약점에 대한 것이며, 2026년 5월 배치도 대부분 기능을 구현하지 않았기 때문에 유사합니다.
전반적으로 이렇게 말하고 싶습니다: 잘하셨습니다, Kristaps와 기여자 여러분! 꼼꼼한 검증을 구현하고, 공격 표면을 제한하며, 다층 방어 조치를 적용함으로써 openrsync는 보고된 취약점의 거의 대부분에 영향을 받지 않았습니다.
다층 방어
Linux에서 다층 방어 조치로 어떤 API와 환경을 사용할 수 있을까요?
gokrazy/rsync가 지원하는 것들을 전통적인 것부터 최신 순으로 살펴보겠습니다.
Linux 마운트 네임스페이스
gokrazy/rsync 프로젝트를 시작한 지 몇 주 만에 저는 권한을 내려놓고 Linux에서 마운트/pid 네임스페이스를 사용해 rsync 서버가 다룰 수 있는 파일 시스템 객체를 제한하는 지원을 추가했습니다.
이 접근 방식은 경로 탐색 공격을 완화하는 데 매우 효과적이지만 권한이 필요하므로 root로 실행하거나 Linux 사용자 네임스페이스(배포판/시스템에서 활성화된 경우) 안에서 실행해야 합니다.
이러한 제약 때문에 마운트 네임스페이스는 서버 설정에는 매우 적합하지만, 일반적으로 사람의 사용자 계정으로 실행되는 일회성 대화형 전송에는 보통 사용할 수 없습니다.
systemd 하드닝
Linux 마운트/pid 네임스페이스 지원을 도입한 동일한 커밋에서 저는 파일 시스템 접근을 홈 디렉터리로 제한하는 systemd 서비스 파일도 포함했고, README에서 사용 사례에 따라 파일 시스템 접근을 더 제한하도록 권장했습니다.
이러한 파일 시스템 제한은 올바르게 설정하면 파일 유출(CVE-2024-12086)과 경로 탐색(CVE-2024-12087) 취약점을 완화합니다.
심볼릭 링크 경쟁 조건(CVE-2024-12747)은 rsync 프로세스를 통한 권한 상승에 의존하지만, DynamicUser 기능 덕분에 우리 프로세스는 다른 사용자보다 더 적은 권한을 가집니다.
마운트 네임스페이스와 마찬가지로 이러한 조치는 서버 설정에는 훌륭하지만, 일회성 대화형 사용에는 설정이 너무 번거롭습니다.
Linux Landlock
Justine의 블로그 글 Porting OpenBSD pledge() to Linux (2022)를 우연히 보고, Linux가 openrsync가 사용하는 OpenBSD의 unveil(2) 시스템 호출과 유사한, 권한 없는 프로세스별 접근 제어를 위한 Landlock API를 제공한다는 사실을 떠올렸습니다. 기본 아이디어는 프로그램이 작업할 디렉터리를 알게 되면 unveil("/home/michael/backups", "rw"); 같은 호출을 하고 더 이상 다른 파일 시스템 위치에 접근할 수 없게 되는 것입니다.
이전에 Go 밋업에서 Landlock에 대해 들어본 적이 있어 Go에서 Landlock을 지원한다는 것을 알고 있었습니다. 2022년에 저는 gokrazy 커널 이미지에 Landlock 지원을 활성화했습니다.
그래서 2025년 3월에 시도해 보았고 파일 시스템 접근을 제한하기 위한 Landlock 지원을 구현했습니다. 처음 예상보다 시간이 조금 더 걸렸는데, 몇 시간이 걸렸습니다. Landlock을 테스트 환경에서 동작하게 만들고(또는 건너뛰고) 하는 과정에서 몇 가지 걸림돌이 있었습니다. 테스트에서는 같은 프로세스에서 실행되는 많은 함수를 정의했는데, 규칙 집합을 반복해서 추가하다 보니 프로세스당 16개(!)라는 정책 레이어 한도를 초과하게 되었습니다.
제대로 설정하고 나니 정말 훌륭한 해결책이었습니다. 이제 권한 없는 gokrazy/rsync 호출에서도 rsync 전송을 소스(읽기 전용)나 대상 디렉터리(읽기-쓰기)로 제한할 수 있습니다! 🎉
Landlock의 단점은 Landlock이 프로세스 수준에서 동작한다는 점입니다. 즉, Landlock 정책에는 프로그램이 필요로 하는 파일이 포함되어야 합니다. 예를 들어 gokrazy/rsync는 사용자 ID 조회를 위해 /etc/passwd를 읽을 수 있어야 하므로, 공격자가 노리는 파일이 /etc/passwd라면 Landlock은 도움이 되지 않습니다.
Go의 os.Root
2025년 2월 Go 1.24 릴리스에서는 os.Root API가 도입되었는데, 이는 경로 탐색에 안전합니다. Go 블로그: 경로 탐색에 안전한 파일 API(Damien Neil, 2025년 3월)를 참고하십시오. 이 API는 Landlock에 비해 더 세밀한 제어(파일 시스템 연산별)를 허용합니다.
Go 1.25(2025년 8월 릴리스)에서는 os.Root에 더 많은 메서드가 추가되어 대부분의 파일 시스템 사용에 편리한 선택이 되었습니다.
저는 gokrazy/rsync의 모든 파일 시스템 사용을 os.Root를 사용하도록 전환했습니다. 이는 아주 잘 맞습니다. 사용자는 입력/출력 디렉터리를 설정하지만, 네트워크를 통해 받은 파일 이름은 신뢰할 수 없으니까요. 바로 이것이 os.Root가 설계된 목적입니다!
처음 os.Root 사용을 검토했을 때, 예를 들어 장치 노드 파일을 생성하는 mknod(2)처럼 이 API로는 본질적으로 수행할 수 없는 시스템 호출이 있다고 생각했습니다. Damien은 이렇게 설명했습니다:
mknod는 지원하지 않을 겁니다.
하지만 안전한 mknod를 활성화하기 위해 이를 사용할 수는 있습니다:
- 대상의 부모 디렉터리를 os.Root.OpenFile로 열고,
- File.Fd로 해당 디렉터리의 파일 디스크립터를 얻은 뒤,
- https://pkg.go.dev/golang.org/x/sys/unix#Mknodat로 파일을 생성하면 됩니다.
실제로 어떻게 보이는지 궁금하다면 internal/receiver/generatormknod_linux.go 15-29행에서 gokrazy/rsync의 사용 예를 확인해 보십시오.
또 다른 걸림돌은 mknodat(2)와 달리 Linux는 bind(2)만 구현하고 bindat은 구현하지 않는다는 점(Linux 7.0 기준)이었습니다!
다행히 Lennart Poettering이 알려준 트릭이 있었습니다. bindat 없이 경로 해석을 건너뛸 수 있는 방법입니다:
그동안에는
/proc/self/<fd>/foobar에 bind하면 될 겁니다…
그리고 실제로 동작합니다! 알려진 안전한 /proc/self/<fd> 뒤에 경로가 아닌 베이스네임(경로의 마지막 구성 요소)만 지정하므로 경로 해석이 건너뛰어지기 때문입니다(49-56행 참고).
이 두 가지 팁 덕분에 gokrazy/rsync v0.3.1 이상에서는 모든 파일 시스템 접근에 os.Root를 사용하므로 모든 파일 시스템 접근이 경로 탐색으로부터 안전합니다! 🥳
결론
검증 부족이 취약점을 만든다
TOCTOU 취약점(CVE-2024-12747, CVE-2026-29518 및 CVE-2026-43619)을 제외하면, 나머지 모든 취약점은 누락되거나 잘못된 입력 검증으로 인해 발생했다는 점이 흥미롭습니다. 세 건의 경우 아예 검증 자체가 없었습니다. 또 다른 경우(CVE-2024-12088)에는 파일 시스템 경로 해석이라는 주제 자체가 워낙 까다로워 기존 검증이 모든 엣지 케이스를 커버하지 못했습니다.
Go에 대한 결론 섹션에서 더 자세히 설명하듯, 가장 가치 있는 구조적 수정은 경계 검사(= 항상 켜져 있는 검증)와 Go의 os.Root 같은 안전한 기본 API를 제공하는 것입니다.
너무 많은 복잡성
몇몇 취약점은 rsync 프로토콜의 진화 과정에서 생겨났습니다. 코드는 원래 충분한 검증을 올바르게 수행했지만, 이후 새로운 기능이 추가되었습니다. 예를 들어 체크섬 알고리즘 협상이 추가(프로토콜 버전 30)되었을 때 검증이 올바르게 업데이트되지 않았습니다. 점진적 재귀가 추가(역시 프로토콜 버전 30)되었을 때도 개별 파일 목록에 대해 타당했던 검증이 점진적 파일 목록을 병합하는 새로운 처리 방식에 맞게 업데이트되지 않았습니다.
복잡성을 피하면 취약점을 피할 수 있습니다! gokrazy/rsync와 openrsync 모두 취약점이 있는 기능을 구현하지 않았기 때문에 12건 중 8건의 보안 취약점에 영향을 받지 않았습니다.
물론 이러한 기능들은 언젠가 누군가에게 가치가 있었기 때문에 rsync에 추가된 것이며, 물론 소프트웨어 개발을 앞으로 전혀 하지 말자는 이야기를 하려는 것은 아닙니다.
하지만 저는 사용 사례의 복잡성에 비례하고 그에 적합한 복잡도를 가진 구현을 사용하는 것이 이상적이라고 생각합니다. 다시 말해, 단순한 사용 사례에는 단순한 구현을 사용하십시오. 완전히 기능을 갖춘 구현은 필요할 때만 사용하십시오.
글을 무작위로 읽기