Go 1.26에 도입되는 실험적 패키지 runtime/secret과 secret.Do를 이용해 민감한 데이터를 사용한 후 메모리를 자동으로 지우는 방법을 설명합니다.
다가오는 Go 변경 사항을 쉽게 설명하는 Accepted! 시리즈의 일부입니다.
사용이 끝난 메모리를 자동으로 지워 비밀 정보 유출을 막기.
Ver. 1.26 • Stdlib • 영향 작음
새로운 runtime/secret 패키지는 함수를 시크릿 모드 로 실행할 수 있게 해줍니다. 함수가 끝나면 그 함수가 사용한 레지스터와 스택을 즉시 지워(제로로 덮어) 버립니다. 함수 내에서 이루어진 힙 할당은, 가비지 컬렉터가 더 이상 도달 불가능하다고 판단하는 즉시 지워집니다.
gosecret.Do(func() { // 세션 키를 생성하고 // 이를 사용해 데이터를 암호화한다. })
이렇게 하면 민감한 정보가 필요 이상으로 메모리에 남지 않도록 도와주어, 공격자가 메모리를 통해 정보에 접근할 위험을 줄일 수 있습니다.
이 패키지는 실험적이며, 애플리케이션 개발자보다는 주로 암호화 라이브러리 개발자를 위한 것입니다.
WireGuard나 TLS 같은 암호 프로토콜에는 "전방 비밀성(forward secrecy)"이라는 속성이 있습니다. 이는 공격자가 장기 비밀(예: TLS의 개인 키)에 접근하더라도, 과거 통신 세션을 복호화할 수 없어야 한다는 뜻입니다. 이를 위해서는 세션 키(특정 통신 세션 동안 데이터를 암복호화하는 데 사용되는 키)를 사용 후 메모리에서 지워야 합니다. 이 메모리를 확실하게 지울 방법이 없다면, 키가 메모리에 무기한 남아 있을 수 있고, 그러면 전방 비밀성이 깨집니다.
Go에서는 런타임이 메모리를 관리하며, 언제 어떻게 메모리가 지워지는지 보장하지 않습니다. 민감한 데이터가 힙 할당이나 스택 프레임에 남아 코어 덤프나 메모리 공격을 통해 노출될 수 있습니다. 개발자들은 종종 리플렉션 같은 신뢰하기 힘든 "해킹" 기법을 사용해 암호화 라이브러리 내부 버퍼를 억지로 0으로 덮어쓰려고 합니다. 그럼에도 불구하고, 개발자가 접근하거나 제어할 수 없는 곳에 데이터가 남을 수 있습니다.
해결책은 민감한 연산 동안 사용된 모든 임시 저장소를 자동으로 지우는 런타임 메커니즘을 제공하는 것입니다. 이렇게 하면 라이브러리 개발자가 우회 기법 없이 더 쉽게 안전한 코드를 작성할 수 있습니다.
runtime/secret 패키지에 Do와 Enabled 함수를 추가합니다:
go// Do는 f를 호출한다. // // Do는 f가 사용하는 모든 임시 저장소가 적시에 지워지도록 보장한다. // 여기서 "f"는 f가 시작하는 전체 호출 트리 전체를 뜻한다. // - f가 사용하는 모든 레지스터는 Do가 반환되기 전에 지워진다. // - f가 사용하는 모든 스택은 Do가 반환되기 전에 지워진다. // - f에 의해 수행된 모든 힙 할당은, 더 이상 도달 불가능하다고 // 가비지 컬렉터가 판단하는 즉시 지워진다. // - f가 panic을 일으키거나 runtime.Goexit을 호출해도 Do는 동작한다. // 이 과정의 일부로, f에서 발생한 panic은 마치 Do에서 발생한 것처럼 // 보이게 된다. func Do(f func())
go// Enabled는 호출 스택 어딘가에 Do가 존재하는지를 보고한다. func Enabled() bool
현재 구현에는 몇 가지 제한 사항이 있습니다:
Do가 단순히 f를 직접 호출합니다.f가 쓰기를 수행하는 전역 변수가 포함되지 않습니다.f 안에서 새로운 고루틴을 시작하려고 하면 panic이 발생합니다.f가 runtime.Goexit을 호출하면, 모든 defer 함수가 실행을 마칠 때까지 지우기가 지연됩니다.f가 panic을 일으키면, panic 값이 f 안에서 할당된 메모리를 참조할 수 있습니다. 그 메모리는 (최소한) 그 panic 값이 더 이상 도달 불가능해지기 전까지는 지워지지 않습니다.마지막 항목은 바로 직관적이지 않을 수 있으니, 예를 들어 보겠습니다. 배열의 오프셋 자체가 비밀인 경우(예를 들어 data 배열이 있고 비밀 키가 항상 data[100]에서 시작한다면), 그 위치에 대한 포인터를 만들지 마십시오(즉, &data[100]에 대한 포인터 p를 만들지 마십시오). 그렇지 않으면, 가비지 컬렉터가 이 포인터를 저장할 수 있습니다. GC는 자신의 일을 하기 위해 모든 활성 포인터를 알고 있어야 하기 때문입니다. 누군가 GC 메모리에 접근하는 공격을 수행한다면, 그 비밀 오프셋이 노출될 수 있습니다.
이 패키지는 주로 암호화 라이브러리를 개발하는 사람들을 위한 것입니다. 대부분의 애플리케이션은 내부적으로 secret.Do를 사용하는 상위 수준 라이브러리를 사용하는 것이 좋습니다.
Go 1.26 시점에서 runtime/secret 패키지는 실험적이며, 빌드 시 GOEXPERIMENT=runtimesecret를 설정해 활성화할 수 있습니다.
secret.Do를 사용해 세션 키를 생성하고 AES-GCM으로 메시지를 암호화해 봅시다:
go// Encrypt는 일시적 키를 생성하고 메시지를 암호화한다. // 전체 민감 연산을 secret.Do로 감싸, 키와 내부 AES 상태가 // 메모리에서 지워지도록 보장한다. func Encrypt(message []byte) ([]byte, error) { var ciphertext []byte var encErr error secret.Do(func() { // 1. 32바이트 일시적 키를 생성한다. // 이 할당은 secret.Do에 의해 보호된다. key := make([]byte, 32) if _, err := io.ReadFull(rand.Reader, key); err != nil { encErr = err return } // 2. 사이퍼를 생성한다(키를 라운드 키들로 확장). // 이 구조체 역시 보호된다. block, err := aes.NewCipher(key) if err != nil { encErr = err return } gcm, err := cipher.NewGCM(block) if err != nil { encErr = err return } nonce := make([]byte, gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { encErr = err return } // 3. 데이터를 Seal한다. // 이 클로저 밖으로 나가는 것은 암호문뿐이다. ciphertext = gcm.Seal(nonce, nonce, message, nil) }) return ciphertext, encErr }
secret.Do는 단순히 원시 키뿐 아니라, 함수 안에서 생성되는 cipher.Block 구조체(확장된 키 스케줄을 포함)도 함께 보호한다는 점에 주목하세요.
물론 이것은 단순화된 예제이며, 메모리 지우기가 어떻게 동작하는지를 보여 줄 뿐 완전한 암호 교환을 구현한 것은 아닙니다. 실제 상황에서는, 복호화가 가능하도록 키를 수신자와 안전하게 공유(예: 키 교환)해야 합니다.
𝗣 21865 • 𝗖𝗟 704615 • 👥 Daniel Morsing, Dave Anderson, Filippo Valsorda, Jason A. Donenfeld, Keith Randall, Russ Cox
★구독하고 새 글을 받아보세요.