복구 사전검증이 통과해도 아직 복원할 수 없는 이유
목차
백업을 복원하기 전에 파일의 SHA-256을 확인했고 1분 뒤 복원 명령이 같은 경로를 다시 열었다. 그사이 경로가 가리키는 파일이나 파일 내용이 바뀌면 검증한 바이트와 복원할 바이트가 달라질 수 있다. 사전검증의 보장 범위를 정하려면 파일 경로, 열린 파일의 정체성, 읽을 내용이 각각 언제까지 유지되는지 구분해야 한다.
Go 1.24.5로 임시 디렉터리 안에서 두 변경을 순서대로 만들었다. current가 처음에는 디렉터리 a를 가리키고, os.OpenRoot(current)로 열린 루트를 보관한다. 그다음 current를 b로 재지정한다. 마지막으로 a/payload의 내용을 같은 파일 안에서 바꾼다. 전체 테스트 코드를 내려받은 디렉터리에서 GOTOOLCHAIN=go1.24.5 go test -v restore_test.go로 실행할 수 있다.
| 확인 시점 | 다시 경로로 열기 | 이미 열린 루트로 열기 | 이미 열린 파일에서 읽기 |
|---|---|---|---|
current를 a에서 b로 교체 | b의 other-v1 | a의 version1 | 아직 열지 않음 |
a/payload에 제자리 쓰기 | 여전히 b의 파일 | a의 변경된 파일 | version2 |
첫 행은 경로가 바뀌어도 열린 루트가 처음의 디렉터리를 가리킨다는 것을 보여 준다. 둘째 행은 열린 파일 핸들이 바이트 스냅샷은 아니다라는 반례다. 테스트는 루트 밖의 ../outside 열기도 거부되는지 확인했다. 바꾼 파일과 링크는 t.TempDir() 안에만 있다.
열린 루트가 해결한 것
os.OpenRoot는 Go 1.24에 들어왔다. 루트 아래의 상대 경로를 열되 ..나 심볼릭 링크로 그 루트 밖을 벗어나는 접근을 막는다. Unix 계열에서는 열린 디렉터리의 핸들을 사용하므로, 나중에 같은 이름이 다른 디렉터리를 가리켜도 기존 Root는 처음 연 디렉터리를 따른다. 단, OpenRoot(current) 자체는 처음 열 때의 심볼릭 링크를 따라간다. 최초 경로가 이미 다른 곳을 가리켰다면 그 잘못된 대상을 안전하게 붙잡는 셈이다. Go 팀의 os.Root 설명, Go os.Root API
Go 1.24.5의 Root에는 ReadFile 메서드가 없다. 이 예제는 root.Open("payload") 후 io.ReadAll을 쓴다. 새로운 Go 문서에 보이는 Root.ReadFile을 1.24 예제에 그대로 복사하면 컴파일되지 않는다. Go Root.Open
열린 파일이 해결하지 못한 것
테스트는 a/payload를 열어 둔 뒤, 다른 핸들로 같은 inode의 처음 8바이트를 version1에서 version2로 덮어썼다. 먼저 연 핸들에서 나중에 읽어도 version2가 나왔다. 이름을 바꿔 다른 파일로 교체한 경우와, 같은 파일의 내용을 바꾼 경우를 섞어 생각하면 이 차이를 놓친다.
다음은 제자리 쓰기를 재현하는 테스트의 핵심이다. root는 앞에서 연 디렉터리 a를 가리키고, a는 테스트용 임시 경로다. 먼저 읽기 핸들을 열고, 같은 파일에 쓰기를 완료한 다음, 처음 핸들에서 읽는다.
held, err := root.Open("payload")
if err != nil {
t.Fatal(err)
}
defer held.Close()
writer, err := os.OpenFile(filepath.Join(a, "payload"), os.O_WRONLY, 0)
if err != nil {
t.Fatal(err)
}
defer writer.Close()
if _, err := writer.WriteAt([]byte("version2"), 0); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
got, err := io.ReadAll(held)
if err != nil {
t.Fatal(err)
}
if string(got) != "version2" {
t.Fatalf("got %q", got)
}핸들을 열 때의 version1이 아니라 이후에 쓴 version2를 읽는다. 파일을 열어 두는 행위는 접근 대상을 붙잡지만, 그 대상의 내용을 복제하지 않는다.
따라서 사전검증에서 해시를 확인하고 파일 핸들을 보관해도, 다른 writer가 같은 파일을 수정할 수 있다면 적용 시점의 바이트는 보장되지 않는다. 열린 루트는 경로 탈출을 줄이는 도구이고, 해시는 읽은 내용의 검사다. 어느 쪽도 이후의 쓰기를 멈추지 않는다. MITRE의 CWE-367은 검사와 사용 사이의 변경을 별도 위험으로 다룬다.
‘없는 파일’도 복구 세대의 일부다
백업에 포함된 파일과 목표 상태를 적은 목록을 manifest라고 하자. 이 목록에는 settings.json에 복원할 새 설정의 해시와, cursor.json은 없어야 한다는 조건이 있다. 현재 복원 대상에는 예전 설정 파일과 예전 읽기 위치를 담은 cursor.json이 남아 있다. 사전검증은 파일 길이와 해시뿐 아니라 관리 대상 목록이 정확히 맞는지도 확인해야 한다. cursor.json을 단순히 목록에서 빼면, 복원 대상에 남아 있던 예전 읽기 위치 파일을 보존해야 하는지 제거해야 하는지 알 수 없다. ‘없음’은 삭제라는 명시적인 목표 상태여야 한다.
두 파일을 순서대로 적용하면 사전검증이 완벽해도 중간 세대가 생긴다. cursor.json을 먼저 지우고 중단되면 예전 설정만 남는다. settings.json을 먼저 바꾸고 중단되면 새 설정에 예전 읽기 위치가 함께 남는다. 어느 쪽도 목표인 “새 설정이 있고 읽기 위치 파일은 없는 상태”가 아니다. 두 개의 원자적 파일 교체가 전체 세대의 원자적 교체를 뜻하지 않는다.
한 대안은 검증된 파일들을 새 세대 디렉터리에 모두 기록하고, 적용 준비가 끝난 뒤 reader가 사용하는 세대 포인터 하나를 바꾸는 방식이다. reader가 한 작업 중 포인터를 여러 번 읽으면 세대를 섞을 수 있으므로 세대 선택도 작업 시작에 한 번 고정해야 한다. 같은 파일시스템의 rename을 쓰더라도 전원 장애 뒤 내구성, 디렉터리 동기화, 이미 열린 핸들, 오래된 세대 정리까지 별도 설계가 필요하다. 디렉터리 세대 전환이 불가능한 시스템이라면 journal에 적용 단계와 이전 값을 남기고 중단 후 완료 또는 되돌림을 명시해야 한다. 어느 방법이든 동시 writer를 막거나 조건부 버전 교체로 거부하지 않으면, 검증한 세대 위에 다른 쓰기가 끼어들 수 있다.
여기서 사전검증은 후보 세대가 그 순간 일관되게 보인다는 보고다. 적용은 현재 상태를 그 세대로 바꾸는 별도 행위다. 적용 직전에는 writer를 막거나 버전 조건을 세우고, 같은 대상과 실제 바이트를 다시 확인해야 한다. 협력하는 프로세스의 잠금은 외부에서 직접 쓰는 프로세스까지 통제하지 못한다.
이 글의 로컬 테스트가 증명한 것은 경로 재지정, 루트의 범위, 열린 파일의 가변성 세 가지다. 백업 manifest의 전체 검증이나 원자적 복원은 실행하지 않았다. 복구 도구의 ‘검증 통과’ 표시를 읽을 때는 검사한 바이트·경로의 정체성·검사 시각을 함께 확인해야 한다. 그 정보가 없으면 초록색 체크 하나로 적용 가능성을 판단할 수 없다.