4P by GN⁺ 2일전 | ★ favorite | 댓글 1개
  • 메모리 안전성스레드 안전성은 분리할 수 있는 개념이 아니며, 스레드 안전성이 없으면 진정한 메모리 안전성을 달성할 수 없음
  • Go처럼 스레드 안전하지 않은 언어의 경우, 단순히 스레드 이슈만으로도 메모리 안전성이 깨질 수 있음
  • Java 등 일부 언어는 동시성 메모리 모델을 통해 데이터 레이스조차 정의된 동작으로 처리하여 언어 수준 안전성을 확보함
  • Go는 데이터 레이스에 취약하며 실제 메모리 안전 침해 사례가 존재함
  • 진정 중요하게 다뤄야 할 속성은 Undefined Behavior(정의되지 않은 동작)의 부재

스레드 안전성 없이는 메모리 안전성을 보장할 수 없음

개념의 혼동: 메모리 안전성 vs 스레드 안전성

  • 최근 메모리 안전성이 크게 주목받고 있으나, 실제로 무엇을 의미하는지 정의가 명확하지 않음
  • 전통적으로 메모리 안전성은 use-after-free 또는 out-of-bounds 메모리 접근을 막아주는 언어를 지칭함
  • 반면, 스레드 안전성은 동시성 버그가 없는 프로그램을 의미하며, 두 개념은 종종 별개로 취급됨
  • 작성자는 이 구분이 실질적으로 쓸모 없다고 주장하며, 우리가 실제로 원하는 것은 Undefined Behavior(UB) 부재임을 강조함

데이터 레이스로 인한 메모리 안전성 침해: Go 예시

  • 메모리 안전성과 스레드 안전성을 따로 취급해온 문제점을 보여주기 위해 Go 언어의 예시 제시
  • Go는 메모리 안전 언어로 분류되나, 아래와 같은 프로그램에서 데이터 레이스만으로도 메모리 오류가 발생함
globalVar를 반복적으로 다른 타입 값(Int, Ptr)으로 변경하면서 동시에 별도 고루틴에서 이를 읽어 메서드를 호출
  • 두 스레드가 겹쳐서 globalVar의 내부 두 포인터(데이터, vtable)를 따로따로 갱신함에 따라, 중간에 읽는 경우 혼합 상태가 발생해서 잘못된 메모리 접근 발생
  • 결과적으로 잘못된 주소(예, 0x2a; 십육진수 42)를 참조하려고 시도해서 프로그램이 오류로 종료함
  • 이 현상은 Go의 인터페이스, 슬라이스 등도 유사하며, 원자적으로 여러 필드를 갱신하지 않아 발생함

다른 언어의 동시성 처리 방식 및 메모리 안전성

  • Java 등 다른 언어도 데이터 레이스 가능성이 있지만, 정의된 동시성 메모리 모델을 적용함으로써 프로그램이 언어 자체를 깨지 않도록 보장함
    • 예시: Java는 멀티스레드 환경에서도 런타임 오류(예, 강제 세그먼트 폴트)에 빠지지 않도록 메모리 모델을 정교하게 설계함
  • 대부분의 언어는 아래 두 가지 방식 중 하나로 동시성 문제를 통제함
    • 모든 동시성 프로그램이 일관적 동작을 보장하도록 메모리 모델 정의(대신 컴파일러 최적화 제한 및 구현 부담 증가)
      • Java, C#, OCaml, JavaScript, WebAssembly 등
    • 강력한 타입 시스템으로 대부분의 데이터 레이스를 금지하고, 소수 예외만 안전하게 처리(Rust, Swift의 strict concurrency)
  • Go는 위 두 옵션 모두를 따르지 않음
    • 데이터 레이스가 없는 경우에만 메모리 안전을 보장함
    • 데이터 레이스 탐지 도구가 있으나, 실제 프로그램에서는 모든 상황을 테스트로 검증하는 데 한계 있음
    • 연구 결과와 현장 경험에서 실제 메모리 안전 위반 사례가 다수 보고됨

Go의 메모리 모델 및 문서화 이슈

  • Go 메모리 모델 공식 문서는 대부분의 레이스는 결과가 제한적이라고는 하지만, 일부 데이터 레이스는 결과가 무한대임을 명확히 설명하지 않음
  • Java/JavaScript와 유사하다는 주장도 있으나, 두 언어는 Go에 비해 동시성 안전성 확보를 위해 훨씬 더 많은 노력을 들임
  • 문서의 일부 세부 섹션에서만 제한적으로 일부 데이터 레이스가 완전히 정의되지 않은 동작을 유발할 수 있음을 언급함

결론: Undefined Behavior(UB)의 부재가 진정한 목표

  • 실질적으로 사용자가 진짜로 원하는 속성은 프로그램이 언어 자체를 깨지 않음(UB 부재)
  • 메모리 안전 침해로 발생하는 각종 보안 취약점은 UB가 실제로 발생했기 때문임
  • UB가 발생하는 순간 이후의 모든 동작은 예측 불가하며, 공격자가 이를 악용할 수 있음
  • '안전' 언어와 '비안전' 언어를 가르는 본질적 차이는 UB의 발생 가능성에 있음
  • 메모리 안전성, 스레드 안전성, 타입 안전성 등 세부로 구분하는 것보다 UB 발생 여부 자체가 핵심
  • 실제로는 안전성에도 스펙트럼이 존재하며, Go는 C보다는 안전하지만 완전한 안전함을 보장하지 않음
  • 데이터를 기반으로 Go에서 실제 안전성을 '증명'하기는 매우 어렵고, 각 언어가 취한 선택의 비직관적 결과를 제대로 아는 것이 중요함
Hacker News 의견
  • 내 Dropbox 팀에서 있었던 일인데, Go 서버에서 데이터 구조체에 동기화 없이 쓰기를 하다가 새롭게 들어온 엔지니어가 반복적으로 segfault를 일으키는 일이 일종의 통과의례였음
    Swift도 같은 문제가 있어서, Swift가 공유 데이터 구조체에 접근할 때 segfault를 아주 잘 일으킬 수 있다는 걸 보여주는 프로그램을 쓴 적이 있음
    Rust나 Java처럼 Go가 메모리-세이프하다고 말하는 건 조금 과장됨
  • Swift는 이 문제를 해결하려고 진행 중이지만, 실세계를 보면 많은 안전하지 않은 코드가 이미 존재해서 변화가 매우 느리고 고통스러움
  • 궁금증이 있는데, 보통 map 같은 기본 구조체는 스레드 세이프하지 않으니까 수정할 때 조심해야 한다는 점이 Go 명세에도 잘 나와 있음
    Dropbox에서 발생한 문제 상황에 대해 자세히 듣고 싶음
  • 여기서 이야기하는 “Rust나 Java 의미의 메모리 세이프티”는 엄밀한 의미에서의 용어 정의가 아니라는 점을 강조하고 싶음
    메모리 세이프티는 PLT(프로그래밍 언어 이론) 개념이라기보단 소프트웨어 보안 용어임
    결국 Go 프로그래머들도 이 차이를 충분히 알고 있으며, 그래서 Go는 “공유를 통해 소통하지 말고, 소통으로 공유하라”는 식의 접근을 기본 premise로 삼음
    물론 현실에서는 이 컨셉이 충분히 실현되지 않았고, Go도 현대적으로는 공유가 많고 동기화가 필요하다는 걸 다들 이해하고 있음
  • 관점을 잡기 위해, Go에서 메모리 세이프하지 않은, 변형된 케이스가 얼마나 있는지, 혹은 Go 프로그램이 실제로 메모리 세이프하지 않을 확률이 얼마나 되는지 자문해보고 싶음
  • Java도 Rust의 의미만큼 메모리 세이프하지 않음
  • 이 이슈는 종종 Rust의 soundness hole 문제와도 비슷하게 반복적으로 나오며, 쓸데없는 문제는 절대 아니지만 우연히 마주칠 가능성이 상당히 낮음
    실제로 Go를 여러 해 운영하면서 이런 버그가 실제로 발생한 적은 거의 없다고 생각함
    Uber가 Go 코드에서 발생한 버그에 대해 상세히 정리했는데, 이 글에서 문제가 실제로 얼마나 자주 일어나는지 표로 정리되어 있음
    Go에서 대부분의 동시 map이나 slice 접근 문제는 같은 슬라이스에 대해 발생하며, “torn read” 현상이 있어야 하므로 실제로는 흔하지 않음
    그럼에도 불구하고 사람들이 이런 문제를 잘 피하는 이유는 아마 보통 충분히 조심하고, 변수를 동시 접근 상황에서 재할당하는 것의 위험성을 잘 인지하고 있어서인 것 같음
    언어 자체에 atomics, channel, mutex가 있으니 실제로는 동시 접근 상황에서 잘못 사용하는 경우가 드물고, race detector도 있어서 이런 문제가 있으면 금방 찾을 수 있음
    성능 저하가 있더라도 torn read 문제는 그냥 고칠 수 있는 이슈라고 생각하고, 실제 운영 중인 Go 코드에서는 큰 문제가 아니었음
    관련 영상
  • Go에서 데이터 레이스 버그를 잡는데 몇 달이 걸린 경험이 있음
    레이스 디텍터도 아무 것도 발견하지 못했고, 모두 무슨 일인지 이해하지 못했음
    결국 루프 카운터가 오버플로우가 나서 같은 계산을 엄청나게 반복하며, 요청이 가끔씩 100ms 대신 3분이 걸리는 현상이 발생함
    프로덕션에서 perf를 이용해 간접적으로 문제를 알게 되어서, 플랫폼 개발자로서의 디버깅 경험이 팀에 큰 도움이 됐음
    다양한 Go 레이스 상황에 많이 노출되다 보니, 개인적으론 Rust가 모든 곳에 도입됐으면 하는 심정임
  • Rust의 메인테이너들도 soundness hole을 버그로 인정하고 있음
    예를 들어 이 이슈는 컴파일러의 큰 리팩터가 필요해 시간이 오래 걸림
  • Uber가 Go 프로그램이 Java 마이크로서비스에 비해 “8배 더 많은 동시성을 노출”한다고 하는데, 여기서 동시성을 카운터블 명사처럼 쓰는 것이 무슨 뜻인지 궁금함
  • Zig도 메모리 세이프함을 주장하는데, Rust의 Send/Sync 타입과 같은 개념이 없음
    실제로는 아직까지 동시 Zig 코드가 적어서 문제가 크게 불거지지 않았지만, 앞으로 async 기능이 더 널리 쓰이면 여러 문제가 한번에 터질 수 있다고 생각함
  • ReleaseSafe로 빌드한 싱글스레드 Zig 프로그램조차, 예를 들어 지역 변수의 수명이 끝난 포인터를 역참조할 때, 모든 최적화 모드에서 메모리 커럽션 위험에 자유롭지 않음
  • Zig의 메모리 세이프티 주장은 농담에 가까움
    물론 C보다는 버그가 줄긴 하지만, 이건 C++도 마찬가지이고 아무도 C++가 메모리 세이프하다고 하지 않음
  • 실제 코드에서, 악의적으로 설계된 것이 아닌 한 데이터 레이스로 인한 취약점이 있는 Go 코드는 본 적 없음
    물론 이게 위험 자체가 완벽히 없다는 의미는 아니지만, Go 애플리케이션의 보안 측면에서는 우선순위 이슈가 아닐 가능성이 높음을 암시함
    반면 C/C++ 코드는 60~75% 현실 취약점이 메모리 세이프티 문제에서 발생함
    메모리 세이프티도 연속선상에 있으며, 어느 수준 이후에는 효용이 줄어든다고 생각함
  • 실제로 데이터 레이스로 인해 취약한 Go 코드를 본 경험이 있음
  • 유지보수의 고통이 CVE보다 훨씬 크다고 느끼는 중임
    익스플로잇이 안 되는 버그라 해도 버그는 결국 고쳐야 함
    초기 개발보다 유지보수에 훨씬 많은 시간이 드니, 유지보수를 줄여줄 수 있다면 초기 런칭 지연이 있더라도 가치 있다고 생각함
  • 메모리 세이프티가 중요한 이유는 대부분의 C 프로그램 CVE가 메모리 세이프티 버그에서 나오기 때문임
    반면 Go에서는 스레드 세이프티가 CVE의 주요 원인이 아님
    이론적으로는 근거가 있지만, 현실에선 크게 부각되지 않음
  • 실제로 스레드에서 어떤 일을 할 수 있는지가 중요함
    메모리를 공유할 때, 데이터 구조체를 망가뜨리면 다른 스레드에서 안전하지 않거나 잘못된 동작이 발생할 수 있음
    예를 들어 한 스레드에서 벡터의 크기를 바꾸는 동안 다른 스레드가 접근하면 순차 실행에선 안전한 작업도 동시성에선 위험해짐
    Go도 여기에 자유로울 수 없음
  • C의 전형적 메모리 세이프티 이슈는 RCE(원격코드 실행)로 이어질 가능성이 높음
    반면, 스레드 세이프티 이슈가 segfault로 끝나면 이는 단순 DoS(서비스 거부) 공격이 전부일 수 있음
    레이스 컨디션이 더 강력한 공격으로 이어질 수 있지만, 트리거하기는 훨씬 더 어려움
  • CVE가 더 치명적이긴 해도, 스레딩 버그로 인한 데이터 커럽션/크래시는 결국 누군가가 트리아지, 분석, 수정을 해야 하는 버그임
  • 대부분의 스레드를 사용하는 언어들이 글로벌 변수와 무제한 공유 메모리 접근을 디폴트로 제공하는 것이 슬픈 현실임
    이게 데이터 커럽션과 레이스의 주요 원인임
    여러 상황에선 프로세스 기반이 스레드보다 더 나은 동시성 모델이지만, 너무 무겁다는 단점이 있음
    만약 각 스레드에 필요한 데이터를 모두 메시지 패싱으로 넘기는 것이 기본이었다면, 이런 문제가 대부분 사라질 것이라 생각함
    어쨌든 우리는 플랫폼에서 글로벌 변수와 공유 메모리를 쓸 자유가 있으니, 스스로 안 쓰면 됨
  • Rust는 스레드 세이프티를 타입 시스템에 내장할 수 있는 대표적 현대 언어임
    Rust의 원래 목표가 메모리 세이프시스템 언어가 아니라 스레드 세이프시스템 언어였고, 메모리 세이프티는 자연스럽게 따라온 결과임
    Rust에서는 구조화된 동시성을 thread::scope 등으로 사용할 수 있어 스레드 작업이 매우 편함
  • 메시지 패싱이 메모리 공유보다 논리적 문제(레이스 컨디션/교착 등)를 더 유발할 수 있으니, 만능 해결책은 아님
  • Go에서는 직접 메모리 공유보단, goroutine 간 통신(채널 등)을 강조하는 경향이 큼
    이 문서 참고
  • goroutine 사이에 채널로 객체를 넘겨도 Go는 sendable 타입, 소유권, read-only 참조 같은 개념이 없어서 안전하게 쓰기가 쉽지 않음
    실제 예시:
    func processData(lines <-chan []byte) {
     for line := range lines {
      fmt.Printf("processing line: %v\n", line)
     }
    }
    
    func main() {
     lines := make(chan []byte)
     go processData(lines)
    
     var buf bytes.Buffer
     for range 3 {
      buf.WriteString("mock data, assume this got read into the buffer from a file or something")
      lines <- buf.Bytes()
      buf.Reset()
     }
    }
    
    위 코드에서 buf.Bytes()는 내부 메모리를 그대로 참조해서 넘기고, Reset() 호출로 backing memory가 재사용되어 processData/main 둘 다 동시에 같은 메모리에 접근하게 되어 데이터 레이스가 발생함
    Rust에서는 이런 코드가 두 mutable reference라서 컴파일조차 안 되며, 소유권 이전이나 카피하도록 강제함
    Go에서는 헷갈리기 쉽고, bytes.Buffer.ReadBytes("\n").String()은 카피를 반환해서 안전하지만, .Bytes()는 위와 같이 위험함
    rust의 채널은 이 문제를 소유권/전송 컨셉으로 근본적으로 막지만 Go는 이런 안전장치가 없음
    결과적으로 mutex보다 느리고, Go 입문자에게는 올바로 쓰기가 더 어려운 경험을 주는 것 같음
  • 실제 golang 프로그램에서는 "공유를 통한 소통" 패턴이 논리적 문제를 대량 발생시키며, 결국 메모리 공유가 일반적임
    즉, "안전"한 레이스나 "안전"한 데드락이 오히려 더 흔해짐
  • 동시성 버그 논의는 대부분의 앱에서 실질적으로 중요한 대다수 버그가 DB 내부에서 락, 트랜잭션, 트랜잭션 격리 등이 잘못 적용돼 생긴다는 이슈를 무시하는 경향이 있음
    PL 이론에서는 Rust의 레이스 프리덤 접근이 매력적일 수 있지만, 현실 앱에서는 어차피 중요한 데이터가 전부 RDBMS에 있고, 예를 들어 SELECT에 FOR UPDATE 안 쓰면 레이스는 얼마든지 생김
    Rust앱이 unsafe 전혀 안 써도 DB에 따라 레이스는 여전히 존재함
  • "메모리 세이프티"라는 용어가 원래 복잡한 개념을 설명하려고 등장했지만, 시간이 지나면서 의미가 확장되거나 축소됨
    Go는 메모리 커럽션 버그를 거의 허용하지 않는 구조임을 실제 익스플로잇의 부재로 알 수 있음
    이 글의 주장대로라면, 대부분의 하이레벨 언어(글에서는 Java만 예외로 침)도 메모리 세이프가 아니게 됨
    Rust가 Go보다 "더" 안전할 수 있지만, “memory safety”는 연속적 스펙트럼이 아니라 통과/실패의 개념임
    만약 언어가 메모리-언세이프하다 주장하려면, POC를 반드시 보여주어야 함
  • 메모리 세이프티란 용어에서 중요한 부분이 "타입 혼동(type confusion)"이라면, Go도 예외는 아님
    글에서 나온 예시는 int를 포인터로 잘못 간주함으로써 메모리 커럽션이 쉽게 일어날 수 있음을 보여줌
    데모에서 일부러 42를 써서 segfault가 나지만, 실주소값을 썼다면 진짜 커럽션이 발생함
  • 데이터 레이스는 언어 스펙이 인지하지 못하는 상태(예: SIGSEGV로 강제종료)에 프로그램이 빠질 수 있으므로, 메모리 세이프티를 위반하는 것임
    따라서 데이터 레이스가 발생 가능한 언어는 메모리-세이프라 할 수 없음
  • 글에서 예로든 것처럼, 타입 혼동을 통한 fat pointer의 torn read나, 슬라이스의 torn read로 인한 out-of-bounds write도 실현 가능함
    이런 케이스에서 메모리-세이프하다고 부를 수 있을지 의문임
  • 용어가 발전하고 의미가 바뀌는 것은 수학과 물리에서도 자주 있는 일임
    이런 문제를 피하기 위해 "가우스 곡률(Gaussian Curvature)" "리만 적분(Riemann Integrals)"처럼 인명을 붙이는 경우가 있음
    "초기 의미를 좁게 남기고, 넓은 의미로 확장된 경우"는 “Galois Group” 사례처럼 있음
    이와 같이 메모리 세이프티도 예외가 아님
  • 저자의 정의로 따질 때 Java가 메모리-세이프하지 않다고 하는 근거가 궁금함
    구체적인 예시를 요청함
  • Go 자체도 공식적으로 메모리 세이프티 정의가 불분명함
    FAQ 등에서 memory safety 언급이나 unions 관련 답변에서 메모리 세이프하다고 암시하지만, 실제로 무슨 뜻인지 명확하지 않음
    Rob Pike의 2012년 발표에서는 "Not purely memory safe"라고 했으나, 'purely'의 의미조차 정의되지 않음
    Go의 race detector 문서에서도 "safe"의 정의가 불분명함(예시 문서)
    외부에선 오히려 Go를 “memory-safe programming language”라며 강하게 주장하는 경우가 잦음
    예시로 fly.io의 보안 문서나, memorysafety.org에서 Go를 memory safe로 분류한 문서 등이 있음
    하지만 같은 문서에서 “Out of Bounds Reads and Writes”도 메모리 세이프티 문제로 서술하는데, 포스트에서 지적한 Go의 에러가 이 조건에 해당함
    최소한 Go와 커뮤니티가 “memory safety”의 정확한 의미를 명확히 해둘 필요가 있다는 생각임
    이런 케이스가 있는 이상, Go를 설명 없이 메모리 세이프 언어라 부르지 않는 것이 바람직함
  • memory safety의 정의도 시대에 따라 조금씩 바뀜
    Go가 만들어질 당시에는 “가비지 컬렉터가 있으면 메모리 세이프”라는 시각이 주류였고, C/C++와 비교하면 훨씬 세이프함