본 글은 https://go.dev/doc/faq를 보며 문서의 내용을 보면서 든 생각을 러프하게 메모한 내용을 바탕으로, ChatGPT가 다듬은 글입니다.
이해에 참고 정도만 하고 직접 검증하면서 epxloit을 진행하시면 될 듯합니다.
Go 익스플로잇 관점에서 Go FAQ를 다시 읽으며 든 생각들
Go 익스 문제를 보다 보면 분명 네이티브 바이너리인데, C/C++을 볼 때 익숙했던 감각이 그대로 통하지 않는다. 스택도 다르게 느껴지고, 런타임은 크고, 타입 정보는 생각보다 많이 남아 있고, 동시성 모델도 언어 차원에서 깊게 들어와 있다. 그래서 처음에는 이것저것 눈에 띄는 문장을 붙잡고 "여기서 취약점이 나올 수도 있지 않을까?" 같은 메모를 적어 두게 된다.
이번 글은 그 러프한 메모를 다시 정리한 것이다. 다만 단순 요약보다는, Go FAQ를 익스플로잇 관점으로 읽으면서 실제로 들었던 질문들을 중심으로 흐름을 잡아 봤다. 처음에는 어떻게 생각했는지, 다시 보니 무엇이 맞고 무엇은 과한 추측이었는지, 그리고 그래서 실제 분석에서는 어디를 먼저 봐야 하는지를 순서대로 적어 보려 한다. 기준은 표준 Go 툴체인인 gc다.
1. Go와 C/C++를 섞으면, 정말 그 경계가 가장 먼저 위험해질까?
FAQ에서 가장 먼저 눈에 들어온 문장 중 하나는 Go와 C/C++를 같은 주소 공간에서 함께 쓸 수는 있지만, 자연스러운 조합은 아니라는 설명이었다.
gc는 C와 호출 규약과 링커 체계가 다르기 때문에 직접 상호 호출하지 않고, 보통 cgo를 통해 경계를 넘는다.
처음에는 여기서 "호출 규약이 다르고 스택 제한도 다르면, 이 차이 자체가 취약점 포인트가 되는 것 아닐까?"라는 생각을 했다.
완전히 엉뚱한 생각은 아니지만, 지금은 조금 다르게 본다. 정말 위험한 것은 ABI 차이 그 자체보다는, 그 경계를 넘는 순간 Go가 제공하던 메모리 안전성, 포인터 규칙, 스택 관리 가정이 약해진다는 점이다.
즉, Go 바이너리에서 가장 먼저 봐야 할 곳이 cgo 경계라는 판단 자체는 여전히 맞다. 다만 이유는 "호출 규약이 다르니까 뭔가 터질 것 같다"보다는, "안전한 Go 영역에서 비안전한 C 영역으로 넘어가며 포인터 전달, 수명 관리, 메모리 소유권 문제가 한꺼번에 열린다"에 더 가깝다.
Go로 작성된 프로그램이라도 cgo가 붙는 순간부터는 다시 전통적인 저수준 메모리 버그를 적극적으로 의심해야 한다.
2. Go 런타임을 먼저 봐야 할까?
Go FAQ를 읽다가 런타임이 GC, 동시성, 스택 관리 같은 핵심 기능을 담당한다는 설명을 보면, 자연스럽게 "그럼 런타임부터 분석해야 하는 것 아닌가?"라는 생각이 든다.
나도 처음에는 특히 스택 기반 취약점을 상상하면서, 결국 RIP를 control 하려면 런타임의 스택 관리부터 이해해야 하지 않을까 생각했다.
이 생각은 절반쯤 맞고 절반쯤 틀리다.
•
맞는 부분
Go 바이너리를 분석할 때 런타임을 모르고는 중요한 동작을 놓치기 쉽다는 점이다.
Go는 Java처럼 가상 머신 위에서 도는 언어는 아니지만, 큰 런타임을 품은 네이티브 바이너리다. 스케줄링, 스택 성장, panic 처리, 타입 메타데이터 활용 같은 핵심 동작이 런타임에 깊게 들어가 있다.
•
틀린 부분은
런타임을 본다고 해서 곧바로 C에서 보던 식의 stack buffer overflow -> RIP control 그림으로 이어지는 것은 아니라는 점이다.
순수 Go 코드의 스택은 런타임이 적극적으로 관리하고, 기본적으로는 메모리 안전한 언어 영역이기 때문에, 고전적인 스택 오버플로우 감각을 그대로 들이대면 잘 맞지 않는다.
3. Go의 panic/recover는 C++ 예외 처리처럼 생각해도 될까?
Go에 전통적인 예외 처리 문법이 없다는 부분을 읽으면서는 C++의 try-catch가 바로 떠올랐다.
특히 바이너리 분석을 할 때 C++ 예외는 stack unwinding 때문에 제어 흐름 추적을 어렵게 만드는 대표적인 요소 중 하나다. 그래서 Go의 panic/recover도 비슷하게 보면 되지 않을까 생각했다.
다시 정리해 보면, 비슷한 점은 분명 있다. panic이 발생하면 현재 goroutine의 스택을 따라 unwind가 진행되고, 그 과정에서 defer가 실행된다. 이 점만 보면 "예외 처리와 유사하다"는 인상은 틀리지 않다.
하지만 차이도 분명하다. Go의 recover는 아무 곳에서나 동작하지 않고, panic이 발생한 같은 goroutine 안의 defer에서만 유효하다.
다른 goroutine의 panic을 대신 받아 주는 것도 불가능하다. 즉, 제어 흐름을 복잡하게 만드는 요소라는 점에서는 예외 처리와 닮았지만, 범위와 복구 방식은 훨씬 더 제한적이다.
그래서 이런 식으로 이해하면 된다.
Go의 panic/recover는 "완전히 다른 무언가"라기보다, C++ 예외보다 더 좁고 통제된 형태의 비정상 제어 흐름 장치다.
서비스 코드 분석에서는 recover가 크래시를 감춘 채 상태를 어정쩡하게 남기는 경우도 있어서, panic 경로가 있는지와 어디서 recover하는지는 생각보다 중요하다.
4. Go가 CSP를 내세운다고 해도, 결국 내부에서는 mutex를 쓰는 것 아닌가?
Go 동시성 모델을 읽다가 가장 먼저 들었던 의문은 이것이었다. 언어 차원에서는 channel과 CSP를 강조하지만, 결국 같은 메모리를 여러 실행 단위가 만지려면 내부에서는 락이든 큐든 무언가가 있어야 하는 것 아닌가?
결론부터 말하면 그렇다. 이 의문 자체는 맞다. Go가 channel을 제공한다고 해서 내부에서 동기화가 사라지는 것은 아니다. 런타임과 라이브러리 내부에는 여전히 락, 큐, 스케줄러 같은 저수준 메커니즘이 필요하다.
다만 Go가 말하고 싶은 핵심은 거기에 있지 않다. 요지는 그런 저수준 동기화를 개발자가 매번 직접 다루게 하지 말고, 더 높은 수준의 모델 위에서 사고하게 하자는 것이다. 그래서 "Go는 mutex를 안 쓴다"는 식으로 이해하면 틀리고, "Go는 mutex를 개발자 눈앞에서 덜 보이게 만들려 한다" 정도가 더 정확하다.
익스 관점에서 보면 이것도 중요하다. channel을 쓰는 코드라고 해서 동시성 버그가 자동으로 사라지는 것은 아니다. 동시성 모델이 더 안전하게 설계되었을 뿐이며, shared state, scheduling, race possibility를 완전히 없애 주지는 않는다.
5. goroutine은 결국 thread 위에서 도는 작업 단위라고 보면 될까?
이 질문도 처음에는 꽤 단순하게 적어 두었다. "goroutine은 thread 위에서 돌아가는 작업 단위이고, 런타임이 queue 기반으로 스케줄링하는 것 아닌가?" 대략적인 방향은 맞지만, 실제로는 그 한 줄보다 조금 더 런타임 중심적으로 보는 편이 낫다.
goroutine은 OS thread와 1:1로 대응하지 않는다. 런타임은 많은 goroutine을 적은 수의 thread에 multiplexing하고, blocking syscall이 생기면 다른 goroutine이 계속 돌 수 있도록 조정한다. 이 덕분에 goroutine은 thread보다 훨씬 가볍고, 같은 프로세스 안에서 매우 많이 만들 수 있다.
여기서 중요한 것은 성능보다 스택 모델이다. goroutine은 작은 스택으로 시작하고, 필요하면 런타임이 스택을 늘리거나 줄인다. 그래서 Go에서 스택은 C/C++에서 상상하던 고정된 선형 메모리 공간이라기보다, 런타임이 적극적으로 관리하는 실행 자원에 더 가깝다.
처음에 "스택 쪽부터 깨야 하나?"라는 생각을 했던 것도 결국 이 지점에서 다시 답이 된다. Go에서는 스택 자체를 고전적인 방식으로 공략하는 그림보다, 런타임이 관리하는 메타데이터나 안전 경계가 어디서 무너지는지를 보는 쪽이 더 맞는 경우가 많다.
6. map 관련 race는 정말 취약점 포인트가 될 수 있을까?
FAQ에서 map 연산이 atomic하지 않다는 부분을 보면, exploit 관점에서는 거의 자동으로 race condition을 떠올리게 된다. 나도 처음에는 "이쪽에서 재미있는 문제들이 꽤 나오겠는데?"라고 생각했다.
지금도 그 감 자체는 유효하다고 본다. 다만 표현은 조금 조심해야 한다. Go의 map은 concurrent-safe 자료구조가 아니다. 여러 goroutine이 읽기만 하는 것은 안전하지만, 읽기와 쓰기 또는 쓰기와 쓰기가 섞이면 안전하지 않다. 구현은 이런 잘못된 동시 접근을 감지해 런타임 fatal error를 내기도 한다.
문제는 여기서 곧바로 "메모리 손상 취약점"으로 점프하는 것이다. 그건 과하다. 더 정확하게는, 이것은 우선 data race이자 프로그램 버그다. 그러나 CTF나 취약점 분석에서 race 자체가 공격 표면이 되는 경우는 충분히 많다. 비정상 상태, 로직 오작동, 크래시, 예상치 못한 흐름을 유도할 수 있기 때문이다.
그래서 지금의 정리는 이렇다. Go 코드에서 map을 보면, 먼저 이게 shared state의 중심인지, 락 없이 갱신되는지, sync.Map이나 다른 동기화 장치 없이 여러 goroutine이 동시에 만지는지를 체크한다. 취약점이 무조건 난다고 단정하지는 않되, race 관점에서는 분명 흥미로운 지점이다.
7. Go의 인터페이스는 C++의 vtable 같은 것으로 봐도 될까?
FAQ에서 동적 디스패치는 인터페이스를 통해서만 가능하다는 문장을 보자마자 든 생각은 단순했다. "그럼 이게 Go 쪽 vtable 비슷한 개념인가?" 비유 수준에서는 어느 정도 맞지만, 정확히 같은 것으로 보면 부족하다.
Go에서 구체 타입의 메서드 호출은 정적으로 결정되고, 인터페이스를 거칠 때만 런타임 동적 디스패치가 일어난다. 이 점에서 인터페이스는 분명 중요한 메타데이터 경계다. 또 인터페이스 값은 단순한 포인터 하나가 아니라, 대략적으로 보면 실제 데이터와 그 데이터의 동적 타입 정보를 함께 들고 다닌다.
즉, "역할은 비슷하지만 표현은 다르다"가 지금의 결론이다. C++처럼 무조건 vtable을 떠올리기보다는, Go에서는 인터페이스를 통해 런타임 타입 정보와 메서드 디스패치가 묶여 움직인다고 이해하는 편이 더 정확하다.
분석 관점에서는 오히려 이 타입 메타데이터가 바이너리 안에 꽤 유용한 힌트로 남는다.
8. Go는 모든 것을 값으로 전달한다는데, 왜 slice나 map은 공유되는 것처럼 느껴질까?
이 부분도 처음에는 한 줄 메모로 끝냈지만, 실제로는 꽤 중요하다. Go는 함수 인자를 전부 값으로 전달한다. 다만 여기서 헷갈리는 것은 "값으로 전달된다"와 "데이터 전체가 복사된다"가 같은 말이 아니라는 점이다.
포인터를 넘기면 포인터 값이 복사되고, slice를 넘기면 slice header가 복사되며, map을 넘기면 map descriptor가 복사된다. interface 역시 인터페이스 값 자체가 복사된다. 그래서 표면적으로는 값 전달이 맞지만, 내부적으로는 같은 backing array나 같은 동적 객체를 계속 가리킬 수 있다.
처음 Go를 읽을 때 이 부분이 자꾸 미끄러지는 이유는 문법이 단순해 보여서다. 하지만 exploit 관점이 아니더라도, alias 관계를 정확히 이해하려면 꼭 짚고 넘어가야 한다. 특히 slice, map, interface는 "값 전달"이라는 한 문장만 믿고 보면 오해하기 쉽다.
9. 힙과 스택은 결국 디버깅으로 확인해야 할까?
러프 메모에는 "이건 디버깅해 보면서 찾아야 할 듯"이라고 적어 두었는데, 지금도 방향 자체는 틀리지 않다고 본다. 다만 좀 더 정확하게 말하면, Go에서는 힙과 스택 배치를 escape analysis가 결정하므로, 단순한 직감보다 컴파일러 판단을 같이 보는 편이 좋다.
함수 밖으로 참조가 새어나가면 힙으로 갈 가능성이 커지고, 지역에서만 끝나는 값은 스택에 남을 수 있다. 큰 객체도 힙으로 갈 수 있다. 결국 저장 위치는 언어 차원의 문법보다 컴파일러 판단에 더 가깝다.
그래서 소스가 있다면 디버깅만 할 것이 아니라 go build -gcflags='all=-m' 같은 출력도 같이 보는 편이 낫다. Go 바이너리를 분석할 때 객체 수명과 저장 위치를 감으로만 판단하면 자주 어긋난다.
10. 포인터 연산이 없으면 정말 안전하다고 봐도 될까?
Go FAQ는 포인터 연산이 없는 이유를 안전성과 구현 단순화에서 설명한다. 이걸 읽으면 자연스럽게 "그럼 주소를 직접 비틀어서 만드는 종류의 버그는 거의 없겠네"라는 생각이 든다. 언어가 보장하는 안전한 영역 안에서는 대체로 맞는 말이다.
하지만 바로 이어서 이런 의문도 생긴다. 정말로 끝일까? map이나 배열, reflect 같은 다른 우회 경로를 통해 사실상 포인터 연산 비슷한 효과를 만들 수는 없는 걸까?
지금 기준에서 정리하면, 안전한 Go 코드만 보면 포인터 연산이 없다는 사실 자체가 꽤 강한 보호 장치다.
다만 실전에서 위험한 지점은 보통 unsafe.Pointer, uintptr, reflection 기반 우회, cgo처럼 그 안전한 영역을 벗어나는 통로에서 열린다.
그러니 "Go에는 포인터 연산이 없으니 끝"이라고 보기보다는, "포인터 연산은 막혀 있지만 그 경계를 우회하는 코드가 있는지 봐야 한다"가 더 정확한 정리다.
11. 왜 Go 바이너리는 큰데, 오히려 분석은 쉬워질 수도 있을까?
Go 바이너리가 큰 이유를 설명하는 FAQ 부분을 읽을 때는, 바로 예전에 들었던 이야기가 떠올랐다. Go 바이너리에는 타입 정보나 심볼 정보가 비교적 많이 남는다는 말이다.
실제로 Go는 정적 링크를 기본으로 하는 경우가 많고, 런타임과 reflection 지원 정보, panic 시 필요한 데이터까지 함께 들어간다.
처음에는 단순히 "바이너리가 크면 분석하기 귀찮겠다" 정도로 생각했지만, 다시 보면 이건 양면적이다.
물론 크고 복잡해지지만, 반대로 타입명과 런타임 패턴 같은 실마리도 많이 남는다.
빌드 옵션에 따라 -ldflags=-w 같은 식으로 DWARF 정보가 제거될 수는 있어도, 전체적으로 Go 바이너리는 "아무 정보도 없는 깡 바이너리"와는 거리가 있는 편이다.
그래서 지금은 이걸 단점만으로 보지 않는다. 구조를 한 번 익히고 나면, 반복적으로 드러나는 타입 정보와 런타임 심볼 덕분에 오히려 복원 속도가 붙는 느낌이 있다.
Go 익스의 난이도는 단순한 바이너리 크기보다는, 그 안에서 어떤 경계가 열려 있는지에 더 많이 달려 있다고 보는 편이 맞다.
12. 그래서 실제로는 어디부터 볼 것인가
FAQ를 읽으면서 이런저런 생각을 적다 보니, 결국 다시 같은 결론으로 돌아오게 된다. Go 익스의 핵심은 Go 자체가 안전하냐 위험하냐를 단순하게 판단하는 데 있지 않다.
더 중요한 것은 Go가 안전하게 감싸는 층과, 그 안전이 무너지는 경계를 구분해서 보는 것이다.
그래서 지금 내가 Go 바이너리를 볼 때 먼저 체크하는 항목은 비교적 명확하다.
1.
unsafe를 쓰는가
2.
cgo나 외부 C 라이브러리 경계가 있는가
3.
shared state를 여러 goroutine이 어떤 방식으로 만지는가
4.
map, channel, mutex, atomic이 실제로 어떻게 섞여 있는가
5.
panic/recover가 제어 흐름을 가리는 구간이 있는가
6.
interface, reflection, 타입 메타데이터가 구조 복원에 어떤 힌트를 주는가
처음 러프 메모를 적을 때는 여기저기서 "재밌는 문제 나오겠다", "여기서 뭔가 터질 수도 있겠다" 같은 감상 위주로 적어 둔 부분이 많았다.
다시 정리해 보니 그 감 자체는 꽤 유효했지만, 방향을 조금 다듬을 필요가 있었다. Go에서 진짜로 봐야 할 것은 고전적인 메모리 버그 하나하나보다, 런타임과 언어가 만들어 둔 안전한 표면이 어디서 깨지는지였다.
Go 익스를 보려면 결국 Go를 "안전한 언어"로만 봐도 안 되고, "결국 C랑 똑같다"라고 봐도 안 된다. 두 시선 사이에서, 무엇이 보호되고 무엇이 경계 밖으로 밀려나는지를 구분하는 감각이 더 중요하다.