How my minimal, memory-safe Go rsync steers clear of vulnerabilities

Michael Stapelberg

미니멀하고 메모리 안전한 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 이상 버전으로 업그레이드하십시오.

세부적인 보안 이슈 내용은 건너뛰고 바로 다음으로 이동하셔도 좋습니다:

배경: 제가 직접 만든 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]
    • SUM_LENGTH는 항상 16(바이트)이며, MD4MD5 체크섬을 담기에 충분합니다.
    • MAX_DIGEST_LEN은 원래 16(바이트)이었지만, rsync를 SHA256이나 SHA512 체크섬 지원과 함께 컴파일하면 더 커질 수 있습니다.
  • 따라서 경계 검사가 무력화되었습니다! 공격자는 범위를 벗어나 쓸 수 있었습니다.
  • 이 문제는 SHA256/SHA512 체크섬 지원을 추가한 2022년 9월의 커밋 ae16850에서 도입되었습니다.
부적절한 체크섬 길이 검증에 대한 전체 설명 펼치기 (Google Security 보고서 인용)

데몬이 체크섬을 읽을 때 두 가지 체크섬을 읽습니다:

  1. 32비트 Adler-CRC32 체크섬
  2. 파일 청크의 다이제스트. 다이제스트 알고리즘은 프로토콜 협상 초기에 결정됩니다. 관련 코드는 아래와 같습니다: 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 바이트까지 가질 수 있습니다. 다음 코드에서 볼 수 있습니다:

io.c

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바이트보다 커질 수 있다는 점입니다:

md-defines.h

#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을 무력화할 수 있습니다.

업스트림 수정:

관련 업스트림 수정은 두 가지입니다:

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-links CLI 플래그는 클라이언트가 서버로부터 받은 심볼릭 링크를 검증하도록 합니다. 기대하는 동작은 심볼릭 링크 대상이 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에서 문제를 재현하는 재현 단계 펼치기

문제를 재현하려면 다음 단계를 따르십시오:

  1. gokrazy/rsync v0.2.7을 체크아웃합니다:

    git clone https://github.com/gokrazy/rsync
    cd rsync
    git checkout v0.2.7
    
  2. 다음과 같이 코드를 패치해 수정을 되돌리고 공격을 실행합니다:

    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.OpenO_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 = /X rsyncd.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.Root API는 나머지 취약점 대부분을 방지합니다.
  • 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-12747TOCTOU권한 있는 파일 유출os.Root
2026-29518TOCTOU권한 있는 파일 유출os.Root
2026-43617검증 누락거부 목록 무력화❌ 논리 버그
2026-43618검증 불충분정보 유출✅ 경계 검사(패닉 발생!)
2026-43619TOCTOU권한 있는 파일 유출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-12747TOCTOU❌ 취약함
2026-29518TOCTOU⚠️ 패치됨
2026-43617검증 누락아니오(호스트 거부 목록)✅ 취약하지 않음
2026-43618검증 불충분아니오(압축)✅ 취약하지 않음
2026-43619TOCTOU⚠️ 패치됨
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가지 다른 설정과 역할/프로토콜 계층화를 보여주는 다이어그램입니다:

4가지 rsync 설정 다이어그램: (1) rsync --daemon (2) rsync -e ssh; rsync://server/module/dir (3) rsync server:/some/path (4) rsync /src

취약점 보고서의 맥락에서 보면, 임의 파일 유출 취약점(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를 활성화하기 위해 이를 사용할 수는 있습니다:

실제로 어떻게 보이는지 궁금하다면 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에 추가된 것이며, 물론 소프트웨어 개발을 앞으로 전혀 하지 말자는 이야기를 하려는 것은 아닙니다.

하지만 저는 사용 사례의 복잡성에 비례하고 그에 적합한 복잡도를 가진 구현을 사용하는 것이 이상적이라고 생각합니다. 다시 말해, 단순한 사용 사례에는 단순한 구현을 사용하십시오. 완전히 기능을 갖춘 구현은 필요할 때만 사용하십시오.

원문은 Michael Stapelberg님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.