재현 가능한 빌드가 실제로 증명하는 것과 한계
체크섬은 파일 전송의 무결성을 보장하지만 빌드 과정의 악의적 변조는 막지 못합니다. 재현 가능한 빌드는 바이너리가 특정 소스 코드에서 생성되었음을 바이트 단위로 검증하여 SolarWinds와 같은 공급망 공격을 방지하는 근본적인 해결책을 제시합니다.
재현 가능한 빌드가 증명하는 것
재현 가능한 빌드는 단 하나의 좁은 사실만을 증명합니다. 즉, 사용자에게 전달된 바이너리가 정확히 해당 소스 코드로 생성된 바이너리라는 점입니다. 누구든 동일한 소스를 가져와 다시 빌드한 뒤 바이트 단위로 비교할 수 있습니다. 검증은 이제 게시자만이 할 수 있는 작업이 아닙니다.
Reproducible Builds 프로젝트는 이를 다음과 같이 정의합니다: "동일한 소스 코드, 빌드 환경, 빌드 지침이 주어졌을 때, 누구든 명시된 모든 아티팩트의 비트 단위로 동일한 복사본을 재현할 수 있다면 그 빌드는 재현 가능하다고 한다." 비교 작업 자체는 해시를 통해 이루어집니다. 모든 어려움은 서로 다른 두 기계가 일치하도록 환경과 지침을 충분히 엄격하게 고정하는 데 있습니다.
체크섬이 이 질문에 답하지 못하는 이유
공개된 체크섬은 파일이 손상 없이 도착했음을 증명합니다. 해당 체크섬에 대한 서명은 그 파일이 키를 보유한 사람으로부터 왔음을 증명합니다. 하지만 이 둘 중 어느 것도 아티팩트가 생성되기 전의 상황에 대해서는 아무것도 알려주지 않습니다. 게시자의 빌드 머신이 침해당했다면, 악성 바이너리도 정상적인 파일과 똑같이 체크섬이 생성되고 서명되므로 다운스트림의 모든 검증을 통과하게 됩니다. 관리자가 저장소에 푸시되지 않은 작업 트리에서 빌드하는 경우에도 마찬가지입니다.
이것이 바로 간극입니다. 소스 코드를 읽고, 서명을 검증하고, 체크섬을 확인하더라도, 저장소에 존재한 적 없는 코드를 실행하고 있을 수 있습니다. 따라서 재현 가능성은 게시된 체크섬으로 다운로드를 검증하는 것과는 다른 주제입니다. 체크섬은 전송 과정을 보호합니다. 반면 리빌드(rebuild)는 전송 이전에 발생한 모든 과정을 보호합니다.
이 공격은 이론적인 이야기가 아닙니다. 2020년 SolarWinds Orion 침해 사고가 정확히 이런 상황이었습니다. 빌드 시스템이 누군가 검토했던 소스 코드와 일치하지 않는 서명된 아티팩트를 생성해 낸 것입니다. 서명은 아티팩트 생성 시점부터 시작되므로, 모든 서명 검증은 정상적으로 통과되었습니다.
재현 가능한 빌드가 증명하지 못하는 것
이 부분은 과대평가되기 쉬우므로, 그 한계를 정확히 이해해야 합니다.
- 소스 코드가 안전하다는 것을 의미하지 않습니다. 공개된 저장소에 커밋된 백도어는 재현 가능하게 빌드되며, 모든 재빌드 수행자도 동일한 악성 소스를 빌드하므로 이를 확인해 줄 뿐입니다. 재현 가능성은 검증의 대상을 소스 트리로 옮길 뿐입니다. 여전히 누군가는 해당 트리를 검토해야 하며, 이것이 바로 오픈 소스 프로젝트의 AI 보조 코드 정책을 포함한 검토 정책이 별도의 통제 수단으로 유지되는 이유입니다.
- 입력값이 안전하다는 것을 의미하지 않습니다. 의존성 또한 빌드 과정의 일부입니다. 빌드 시점에 해결된 악성 패키지는 아티팩트에 컴파일되어 포함되며, 동일한 의존성을 해결하는 모든 재빌드 수행자도 귀하와 동일한 결과를 내놓습니다. 이것이 바로 npm 공급망 공격이 서버에 도달하는 방식이며, 재현 가능한 빌드는 이를 충실하게 재현할 뿐입니다.
- 툴체인이 정직하다는 것을 의미하지 않습니다. 컴파일러가 침해되었다면, 해당 컴파일러를 사용하는 모든 재빌드 수행자는 동일하게 침해된 결과물을 생성하며, 모든 검증 결과는 일치하게 됩니다. 재현 가능성은 그러한 공격의 비용을 높일 뿐, 공격 자체를 탐지하지는 못합니다.
- 취약점에 대해서는 아무것도 말해주지 않습니다. 비트 단위까지 완벽하게 재현된 오래된 라이브러리는 여전히 알려진 결함을 가진 오래된 라이브러리일 뿐입니다. 따라서 서버의 알려진 CVE 점검은 별도의 일정에 따라 계속 수행해야 합니다.
재현 가능성이 제거하는 것은 특정 공격자의 위치, 즉 빌드 머신과 소스에서 바이너리에 이르는 전체 경로에 대한 위협입니다. 패키지가 재현 가능해지기 전까지는, 게시자 외의 누구도 해당 경로를 전혀 검사할 수 없습니다.
동일한 소스에서 서로 다른 바이트가 생성되는 이유
대부분의 소프트웨어는 기본적으로 재현 가능하지 않으며, 그 원인은 단순합니다. 컴파일러와 아카이브 형식은 이를 실행한 시스템에 대한 정보를 기록합니다.
- 타임스탬프.
tar,ar,zip형식은 파일 수정 시간을 저장하므로, 다른 시간에 빌드하면 출력 파일이 변경됩니다. - 경로. 디버그 정보는 절대 빌드 경로를 기록하므로, 코드가 동일하더라도
/home/alice/src에서 빌드한 결과물과/build/pkg에서 빌드한 결과물은 서로 다릅니다. - 순서. 디렉터리를 읽을 때 파일 시스템 순서대로 항목을 반환하므로, 링크 라인이나 아카이브 멤버 순서가 시스템마다 달라질 수 있습니다.
- 식별 정보. 빌드 스크립트는 빌드한 사용자의 이름, 호스트 이름 또는 로케일 정보를 포함합니다.
- 빌드 시점의 결정. CPU 기능을 감지하거나 난수를 생성하는 과정에서 출력 결과가 소스 코드가 아닌 시스템 환경에 의존하게 됩니다.
첫 번째 원인이 발생하는 과정을 약 10초 만에 확인할 수 있습니다.
mkdir -p /tmp/rb && cd /tmp/rb
echo hello > a.txt && tar cf one.tar a.txt
sleep 2
echo hello > a.txt && tar cf two.tar a.txt
sha256sum one.tar two.tartar 헤더가 a.txt의 수정 시간을 저장하고 있고, 파일을 다시 작성하면서 해당 시간이 2초 뒤로 밀렸기 때문에 두 해시값이 다르게 나타납니다. 파일의 실제 내용은 바이트 단위로 동일합니다. 메타데이터를 고정하면 이 문제가 해결됩니다.
export SOURCE_DATE_EPOCH=1700000000
tar --sort=name --mtime="@${SOURCE_DATE_EPOCH}" --owner=0 --group=0 --numeric-owner -cf three.tar a.txt
touch a.txt
tar --sort=name --mtime="@${SOURCE_DATE_EPOCH}" --owner=0 --group=0 --numeric-owner -cf four.tar a.txt
sha256sum three.tar four.tar이제 아카이브 헤더의 어떤 필드도 현재 시스템 상태의 영향을 받지 않으므로 해시값이 일치합니다. --sort=name은 순서를 고정하고, --mtime는 시간을 고정하며, 소유권 플래그는 사용자 ID가 기록되는 것을 방지합니다.
diffoscope으로 차이점 확인하기
두 빌드 결과물이 다를 때 sha256sum은 단지 다르다는 사실만 알려줍니다. diffoscope은 사람이 읽을 수 있는 형태로 그 이유를 설명해 줍니다. 이 도구는 양쪽 파일을 재귀적으로 압축 해제하고, 바이너리 형식을 텍스트로 변환한 뒤 텍스트 간의 차이를 비교합니다. Debian 패키지, ELF 바이너리, tar 및 ZIP 아카이브, PDF, SQLite 데이터베이스를 비롯해 100가지가 넘는 형식을 지원합니다.
sudo apt install -y diffoscope
diffoscope one.tar two.tar위의 tar 쌍에 대한 보고서는 짧습니다. 내용을 줄여서 보면 다음과 같습니다.
--- one.tar
+++ two.tar
├── file list
│ @@ -1 +1 @@
│ -rw-r--r-- 0/0 6 2026-08-18 10:14:02.000000 a.txt
│ +rw-r--r-- 0/0 6 2026-08-18 10:14:04.000000 a.txt이것이 진단의 전부입니다. 크기와 경로, 권한은 같지만 수정 시간이 다릅니다. 실제 패키지는 훨씬 긴 보고서를 생성하므로, 파일로 저장한 뒤 브라우저에서 여는 것이 좋습니다.
diffoscope --html report.html build1.changes build2.changesdiffoscope은 입력값이 동일하면 0, 다르면 1, 오류가 발생하면 2를 반환합니다. 따라서 별도의 래퍼 스크립트 없이 CI 작업에 바로 통합할 수 있습니다. 소규모 VPS를 사용 중이라면 diffoscope 대신 diffoscope-minimal를 설치하십시오. 전체 패키지를 설치하면 사용하지 않을 대규모 형식 지원 도구들이 함께 설치되기 때문입니다.
SOURCE_DATE_EPOCH가 해결하는 문제와 그 한계
SOURCE_DATE_EPOCH는 하나의 숫자를 담고 있는 환경 변수입니다. 이 숫자는 1970년 1월 1일 UTC 이후 경과한 시간을 초 단위로 나타낸 소스 코드의 마지막 수정 시간입니다. 이 변수를 지원하는 빌드 도구는 운영체제에 현재 시간을 묻는 대신 이 숫자를 사용합니다. 버전 관리 시스템에서 이 값을 설정하면 빌드 시점이 아닌 소스 코드의 변경 이력을 기준으로 값이 결정됩니다.
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)Debian 패키지에서는 debhelper가 changelog를 기반으로 이 값을 자동으로 내보냅니다. debian/rules에서 수동으로 설정하는 방법은 다음과 같습니다.
export SOURCE_DATE_EPOCH ?= $(shell dpkg-parsechangelog -STimestamp)이 기능은 도구별로 지원 여부가 결정되며 전역적으로 적용되지 않습니다. cmake 3.8 이상, gcc 7 이상, rpm 4.13 이상, 그리고 Docker buildx 0.10 이상 버전에서 이를 읽어 들입니다. 직접 작성한 스크립트는 별도로 구현하지 않는 한 이 변수를 인식하지 않습니다. 스크립트에서 date을 호출한다면 다음과 같이 변수를 전달하십시오.
BUILD_DATE="$(date --utc --date="@${SOURCE_DATE_EPOCH:-$(date +%s)}" +%Y-%m-%d)"구현 시 반드시 지켜야 할 규칙이 하나 있습니다. 변수가 이미 설정되어 있다면 해당 값이 빌드 과정에서의 현재 시간으로 간주되므로, 호출자가 전달한 값을 절대 덮어쓰지 마십시오.
컨테이너 이미지도 다른 형태의 포장 방식을 사용할 뿐 동일한 문제를 겪습니다. Docker buildx 0.10 이상 버전은 셸에 설정된 SOURCE_DATE_EPOCH를 빌드 인자로 전달합니다. 레이어 내부 파일의 타임스탬프를 재작성하려면 내보내기 도구의 지원이 필요하며, BuildKit 0.13 버전부터 이 기능이 추가되었습니다. 문서화된 방식에 따라 결과를 레지스트리로 푸시하는 방법은 다음과 같습니다.
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
docker buildx build --output type=image,name=registry.example.com/app:1.0,push=true,rewrite-timestamp=true .reprotest를 이용한 자체 빌드 테스트
reprotest는 동일한 소스를 두 번 빌드합니다. 이때 두 빌드 사이의 환경을 의도적으로 변경한 뒤 결과를 비교합니다. 이러한 변형이 이 도구의 핵심입니다. 기본적으로 빌드 경로, 시간, 시간대, 로캘, umask, 호스트 이름, 사용자 및 그룹, CPU 개수, 홈 디렉터리, 파일 정렬 순서를 변경합니다.
sudo apt install -y reprotest
reprotest . -- null-- 뒤에 오는 모든 항목은 빌드 환경 백엔드를 선택하며, null는 현재 사용 중인 시스템을 의미합니다. -vv -d을 추가하면 검사를 위해 임시 디렉터리를 유지할 수 있습니다. 예를 들어 reprotest . -vv -- null -d과 같이 사용합니다. reprotest auto -- null를 사용하면 현재 소스 트리의 유형을 자동으로 파악하게 할 수 있습니다.
일부 변형은 권한이나 추가 패키지가 필요하며, 실행할 수 없을 경우 오류 메시지를 출력하며 실패합니다. 전체 과정을 root 권한으로 실행하기보다는 해당 변형을 끄는 것이 좋습니다.
reprotest --vary=-user_group,-domain_host,-fileordering auto -- nullreprotest가 보고하는 모든 내용은 나중에 재빌드 시스템이 공개적으로 보고하게 될 문제이며, 여기에는 귀하 프로젝트의 이름이 포함됩니다.
리빌더 판정의 의미
리빌더는 게시자의 장비가 아닌 별도의 머신을 의미합니다. 리빌더는 게시된 소스 코드와 기록된 빌드 환경을 가져와 패키지를 다시 빌드한 뒤, 그 결과물을 아카이브에 있는 아티팩트와 비교합니다. 이 판정이 가치를 지니는 이유는 오직 해당 머신이 독립적이기 때문입니다.
Debian은 .buildinfo 파일에 빌드 환경을 기록하며, dpkg-buildpackage가 .deb 옆에 이 파일을 작성합니다. 여기서 중요한 부분은 각 필드입니다. Installed-Build-Depends은 빌드에 영향을 줄 수 있는 모든 설치된 패키지를 정확한 버전과 함께 나열합니다. Build-Path은 빌드가 실행된 위치를 기록합니다. Environment은 중요하다고 판단되는 환경 변수를 기록합니다. Checksums-Sha256는 출력물을 다룹니다. 이 파일은 두 번째 빌드 시도를 위한 레시피가 됩니다.
sudo apt install -y devscripts mmdebstrap
debrebuild --buildresult=./artifacts --builder=mmdebstrap hello_2.10-2_amd64.buildinfodebrebuild은 buildinfo를 읽고 그 안에 명시된 정확한 의존성 버전을 snapshot.debian.org에서 가져옵니다. 따라서 오늘 수행하는 리빌드에서도 원래 빌드 당시에 존재했던 패키지 버전을 그대로 사용할 수 있습니다. mmdebstrap 빌더는 chroot 설정이나 슈퍼유저 권한이 필요하지 않습니다. 생성된 아티팩트를 diffoscope을 사용하여 아카이브 사본과 비교하십시오.
Arch Linux는 rebuilderd를 실행하여 이 과정을 지속적으로 수행하고 판정 결과를 게시합니다.
rebuildctl -H https://reproducible.archlinux.org pkgs ls --name rebuilderd상태는 GOOD, BAD, UNKWN로 나뉘며, 각 상태를 문자 그대로 해석하면 잘못된 결론에 도달하기 쉽습니다. GOOD은 독립적인 제3자가 동일한 바이트를 얻었음을 의미하며, 이는 빌드 과정에 대한 강력한 보증일 뿐 소스 코드 자체에 대한 보증은 아닙니다. BAD는 거의 공격을 의미하지 않습니다. 일반적인 원인은 패키징 과정에서 고정하지 못한 타임스탬프나 경로 문제이며, 이것이 바로 rebuilderd가 실패 시 diffoscope 보고서를 첨부할 수 있는 이유입니다. UNKWN은 아무도 테스트하지 않았음을 의미하며, 테스트되지 않은 패키지는 통과된 패키지가 아닙니다.
따라서 운영 원칙은 간단합니다. BAD 판정이 나오면 보고서를 읽어야 합니다. 보고서에 타임스탬프, 빌드 경로, 멤버 순서 문제만 나타난다면 패키징 버그를 리포트하십시오. 만약 그러한 설명 없이 실행 가능한 코드 자체가 다르다면, 해당 빌드의 배포를 즉시 중단하고 문제를 상위 단계로 보고하십시오.
현재 Debian의 재현 가능성은 어느 정도입니까?
The data behind this chart
[
{
"label": "unstable",
"percent_reproducible": 94.2,
"tested_count": "41,163"
},
{
"label": "forky",
"percent_reproducible": 93.4,
"tested_count": "39,059"
},
{
"label": "experimental",
"percent_reproducible": 67.0,
"tested_count": "588"
}
]이 글을 작성한 시점에 amd64 아키텍처의 unstable 버전은 테스트된 41,163개의 패키지 중 94.2%가 재현 가능했습니다. Experimental 버전은 588개의 패키지를 대상으로 67.0%의 재현율을 보였는데, 이는 아직 수정이 완료되지 않은 패키지들이 포함되어 있어 예상 가능한 수치입니다.
이 수치는 tests.reproducible-builds.org의 Debian 페이지에서 가져온 것이며, 2026-08-18에 확인했습니다. 해당 페이지에는 "Last update: 2026-08-18 16:02 UTC"라는 타임스탬프가 찍혀 있었습니다. 이 수치는 계속 변합니다. 6개월 뒤에 이 문단을 인용하기보다는 추적기(tracker)를 직접 확인하십시오.
백분율보다 더 중요한 주의 사항이 하나 있습니다. 이 프레임워크는 각 패키지를 자체 하드웨어에서 두 번 빌드하며, 두 빌드 사이의 환경을 다르게 설정한 뒤 결과를 비교합니다. 이는 패키지가 재현 가능하게 빌드될 수 있는지를 측정하는 것입니다. 아카이브에 있는 .deb가 게시된 아티팩트와 일치하는지 확인하는 작업은 아니며, 이는 리빌더(rebuilder)가 별도로 수행해야 할 작업입니다. 두 수치 모두 유용합니다. 각각 다른 질문에 답을 주지만, 사람들은 종종 첫 번째 수치를 두 번째 수치인 것처럼 인용하곤 합니다.
개인 서버에서 수행할 작업
배포판을 새로 빌드할 필요는 없습니다. 일반 서버로 옮겨야 할 부분은 작고 비용도 저렴합니다.
- 툴체인을 고정하십시오. 태그로 참조하는 베이스 이미지는 예고 없이 변경될 수 있습니다. 다이제스트(digest)로 참조하고, 릴리스와 함께 해당 다이제스트를 기록하십시오.
- 입력값을 기록하십시오. 락파일(lockfile), 이미지 다이제스트, 컴파일러 버전을 아티팩트와 함께 보관하십시오. 환경을 재구성할 수 없는 빌드는 다시 빌드할 수 없으며, 따라서 검증도 불가능합니다.
- CI에서 두 번 빌드하고 출력값이 다르면 작업을 실패 처리하십시오. 빌드 비용은 한 번 더 들지만, 누군가 비결정론적 요소를 도입한 당일에 이를 포착할 수 있습니다. 1년 뒤 장애가 발생했을 때 찾는 것보다 훨씬 효율적입니다.
- 컴파일러가 포함하는 경로를 제거하십시오. Go의 경우
go build -trimpath -buildvcs=false은 빌드 디렉터리와 버전 관리 스탬프를 제거하며,go version -m ./app은 실제로 바이너리에 포함된 내용을 출력합니다. - 배포한 항목의 해시를 보관하십시오. 실행 중인 바이너리가 특정 소스 리비전과 일치하는지 확인해야 할 때, 이 기록만이 유일한 답변을 제공합니다.
CI 검사는 다음 네 줄로 구성됩니다.
set -eu
./build.sh && mv dist/app app.1
./build.sh && mv dist/app app.2
diffoscope --text - app.1 app.2diffoscope은 두 아티팩트가 다를 경우 0이 아닌 종료 코드를 반환하므로, 작업은 자동으로 실패하며 로그에 읽기 쉬운 설명이 남습니다. 이것이 바로 단일 저장소 규모로 축소된 핵심 개념입니다. 즉, 바이너리가 특정 소스 트리에서 생성되었다는 주장은 제2의 머신이 검증할 수 있어야 한다는 것입니다.
FAQ
재현 가능한 빌드(reproducible build)라면 소프트웨어가 안전하다는 뜻입니까?
아닙니다. 이는 바이너리가 소스 코드와 일치함을 증명할 뿐입니다. 공개된 소스 트리에 커밋된 백도어는 재현 가능하게 빌드되며, 모든 재빌드 수행자(rebuilder)는 동일한 악성 소스를 빌드했으므로 이를 확인하게 됩니다. 알려진 CVE가 포함된 패키지도 완벽하게 재현되지만 여전히 취약합니다. 재현 가능성은 공격자의 위치 중 하나인 빌드 머신과 소스에서 바이너리로 이어지는 경로를 제거할 뿐입니다. 소스 코드를 읽고 취약점을 추적하는 작업은 별개의 영역이며, 재현 가능성이 이를 대신해주지는 않습니다.
소스 코드를 변경하지 않았는데 왜 두 빌드 결과가 다릅니까?
거의 항상 타임스탬프, 경로 또는 순서 문제 때문입니다. tar나 zip 같은 아카이브 형식은 파일 수정 시간을 저장하므로, 서로 다른 시점에 체크아웃을 수행하면 결과물의 바이트가 달라집니다. 디버그 정보는 절대 빌드 경로를 기록하므로, /home/alice/src과 /build/pkg는 동일한 코드라도 서로 다른 바이너리를 생성합니다. 디렉터리 읽기 작업은 파일 시스템 순서대로 항목을 반환하므로, 링크 라인에 포함된 오브젝트 파일의 순서가 다른 머신에서는 바뀔 수 있습니다. diffoscope build1 build2을 실행하면 추측할 필요 없이 어떤 문제인지 보고서가 알려줍니다.
SOURCE_DATE_EPOCH란 무엇이며 반드시 설정해야 합니까?
이는 1970년 1월 1일 UTC 이후 경과된 초 단위의 마지막 소스 수정 시간을 담는 표준 환경 변수입니다. 이를 지원하는 도구는 시스템 시계를 읽는 대신 이 값을 사용합니다. 버전 관리 시스템에서 export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)을 사용하여 설정하십시오. 이는 자동이 아니며 일반적인 해결책도 아닙니다. 이를 구현한 도구만 이 값을 읽으며, 빌드 스크립트가 직접 이 값을 읽도록 처리해야 합니다. 따라서 date를 호출하는 스크립트는 직접 수정하기 전까지 계속 현재 시간을 찍어낼 것입니다.
재빌드 수행자가 BAD를 보고하면 어떻게 해야 합니까?
다른 작업을 수행하기 전에 보고서를 먼저 읽으십시오. BAD 판정은 독립적인 재빌드 결과가 동일한 바이트를 생성하지 않았음을 의미하며, 일반적인 원인은 공격보다는 패키징 과정의 비결정성(nondeterminism)인 경우가 많습니다. 바로 이러한 이유로 rebuilderd는 diffoscope 보고서를 생성할 수 있습니다. 차이점이 타임스탬프, 빌드 경로, 파일 순서라면 이는 보고할 가치가 있는 패키징 버그입니다. 만약 이러한 설명 없이 실행 코드 자체에 차이가 있다면, 해당 빌드의 배포를 즉시 중단하고 아티팩트를 보존한 뒤 게시자에게 문제를 제기하십시오.