Go 악성코드, 메모리에서 어디까지 분석할 수 있을까? — 논문 리뷰
Go 악성코드, 메모리에서 어디까지 분석할 수 있을까? — 논문 리뷰
메모리에서 서버 주소나 파일 경로를 찾으면, 그 값이 어디에 쓰이는지 궁금해집니다. 연결 설정에 있는 주소인지, 파일 API의 인자로 쓰이는 경로인지에 따라 해석이 달라집니다. 이 논문을 읽으며 궁금했던 것도 “찾은 문자열이 프로그램에서 어떤 역할을 하는지 알 수 있을까?”였습니다.
이번에 읽은 논문은 Memory Forensics Techniques for Automated Detection and Analysis of Go Malware입니다. Go 런타임의 구조를 이용해 메모리 속 문자열과 함수 정보, goroutine의 스택을 분석하는 연구입니다. 저자들은 이를 go_strings, go_functions, go_goroutines라는 Volatility 3 플러그인으로 구현했습니다. 공개 구현
| 항목 |
내용 |
| 저자 |
Hala Ali, Andrew Case, Irfan Ahmed |
| 게재 |
Forensic Science International: Digital Investigation, 57 Supplement, 302118, 2026년 6월 |
| 원문 |
DOI · DFRWS 공개 PDF |
먼저 문자열의 포인터와 길이를 읽는 원리를 살펴보겠습니다. 이후 Screenshotter, BRICKSTORM, Pantegana 사례를 통해 함수 인자와 HTTP 연결 객체를 어떻게 해석하는지 정리하겠습니다.
1. Go 문자열의 경계는 어디에 있을까
여기서 말하는 Go 문자열은 Go 언어의 string 타입 값입니다. 예를 들어 s := "hello"는 변수 s에 문자열 "hello"를 저장하는 코드입니다.
Go의 string은 바이트열입니다. 길이는 문자의 개수가 아니라 바이트 수이며, ABI에서는 데이터를 가리키는 포인터와 길이로 표현됩니다. 이 논문이 다루는 x86-64 환경에서는 각각 8바이트입니다. 이 두 요소를 담은 부분이 문자열 헤더이고, 실제 데이터는 포인터가 가리키는 곳에 있습니다. Go 언어 명세 · Go 1.25.5 ABI 문서
다음은 이해를 돕기 위한 가상의 메모리 배치입니다. 0x5000부터 adminupload라는 11바이트가 연속해서 저장돼 있다고 가정해 보겠습니다.
| 헤더 주소 |
데이터 포인터 |
길이 |
참조하는 값 |
0x7000 |
0x5000 |
5 |
admin |
0x7020 |
0x5005 |
6 |
upload |
일반적인 문자열 검색에서는 adminupload가 하나의 값처럼 보일 수 있습니다. 하지만 첫 번째 헤더는 앞의 5바이트를, 두 번째 헤더는 그다음 6바이트를 참조합니다. 문자열 끝에 구분 문자가 없어도 포인터와 길이로 admin과 upload의 경계를 확인할 수 있습니다.
Go의 string이 항상 유효한 UTF-8인 것은 아닙니다. 예를 들어 string([]byte{0x41, 0x00, 0xff})도 길이가 3인 문자열입니다. 널 바이트나 UTF-8로 해석되지 않는 바이트가 포함될 수 있으므로, 출력 가능한 문자를 중심으로 후보를 고르는 추출기가 Go의 모든 문자열을 열거한다고 보기는 어렵습니다. Go의 문자열과 바이트 설명
2. 런타임 메타데이터에서 분석을 시작한다
포인터와 길이처럼 보이는 값 두 개를 찾았다고 바로 문자열로 해석할 수 있는 것은 아닙니다. 해당 위치를 어떤 구조로 읽어야 하는지, 참조한 주소가 어느 영역에 속하는지도 알아야 합니다. 함수와 스택을 분석할 때 역시 코드 주소에 함수 정보를 연결할 수 있어야 합니다.
분석에 사용하는 Go 런타임 메타데이터는 다음과 같습니다. 메모리 영역과 함수, 타입에 관한 정보를 담고 있으며, 세부 필드와 배치는 Go 버전에 따라 달라집니다. Go 1.25.5 런타임 소스
| 구조 |
분석에서 사용하는 정보 |
pclntab과 관련 테이블 |
코드 주소와 함수의 연결, 함수명, 소스 파일·줄 번호, 스택 해석에 필요한 자료 |
moduledata |
실행 이미지의 메모리 영역과 함수·타입 관련 테이블의 위치 |
typelinks, itablinks |
타입 정보와 인터페이스 구현 정보를 따라가기 위한 참조 |
타입 정보는 객체의 필드를 해석하는 데 쓰이며, 함수 정보는 특정 코드 주소가 어느 함수에 속하는지를 알려 줍니다.
함수명과 타입 등 Go 메타데이터를 복구하는 도구로는 GoReSym도 있습니다. GoReSym 공식 저장소
논문은 페이지 캐시에 남은 실행 파일로 함수명과 소스 경로를 보완하는 방법도 설명합니다. 논문 §3.1.2
3. go_strings는 문자열의 값과 위치를 함께 읽는다
go_strings는 Go의 메모리 관리 구조를 따라가며 힙 객체 후보를 찾습니다. span은 Go 런타임이 힙을 관리하는 메모리 영역이고, 슬롯은 그 안에서 객체가 놓이는 고정 크기의 자리입니다.
플러그인은 allspans와 mspan을 통해 span과 슬롯의 위치를 파악합니다. 이어 타입 후보를 대조하고, 문자열로 해석할 수 있는 필드에서 포인터와 길이를 읽습니다. 크기가 같은 객체라도 타입은 다를 수 있으므로 참조 주소와 길이, 실제 내용도 함께 검사합니다. 공개 문자열 분석 코드
할당 슬롯의 크기와 타입 자체의 크기도 항상 같지는 않습니다. Go의 작은 객체 할당에는 크기 등급이 사용되므로 여유 공간이 생길 수 있습니다. 따라서 크기는 타입 후보를 좁히는 단서로 보아야 합니다. Go 1.25.5 할당기 설명
논문은 할당 비트맵을 이용한 객체 복구를 설명하지만, 이 글에서 참고한 공개 코드는 현재 할당된 슬롯만 선별하지 않고 슬롯들을 탐색합니다. 따라서 추출 결과를 현재 할당된 객체의 목록으로 해석해서는 안 됩니다. 논문 §3.2.1 · 확인한 코드
Screenshotter 출력에서 볼 부분
그림 1. Screenshotter의 Go 문자열 추출 결과. 출처: 논문 원문 · Figure 4(b) · PDF 7쪽.
Figure 4(b)를 읽을 때는 user1password라는 값과 헤더·데이터의 위치를 같이 보았습니다. 길이는 13바이트이고, 헤더는 heap, 데이터는 rodata로 표시됩니다.
이 문자열의 헤더는 힙에 있지만, 실제 바이트는 읽기 전용 영역에 있습니다. 따라서 “힙에서 문자열을 찾았다”는 결과만으로 내용이 실행 중 새로 만들어졌다고 판단할 수는 없습니다.
전역 string 변수에 실행 중 만들어진 문자열을 대입하면, 헤더가 전역 데이터 영역에 있어도 실제 바이트는 힙에 있을 수 있습니다. Go 1.25.5 문자열 변환 소스
4. go_functions는 호출 지점의 인자를 분석한다
문자열의 범위를 알아냈다면 다음에는 그 값이 어떤 코드에서 사용되는지 보고 싶어집니다. go_functions는 함수 메타데이터를 복원하고, 함수의 출처를 구분하며, 인자의 타입과 배치를 해석합니다. 이때 메서드의 타입 정보, 함수 시그니처 자료, 컴파일러가 남긴 인자 배치 정보를 사용합니다. 공개 함수 분석 코드
함수의 출처를 나누면 런타임·표준 라이브러리 코드 사이에서 애플리케이션 코드를 찾아 분석 범위를 좁힐 수 있습니다. 애플리케이션으로 분류됐다는 사실 자체가 악성 여부에 대한 판정은 아닙니다.
인자를 읽으려면 ABI에 정의된 호출 규약도 고려해야 합니다. Go 1.17은 Linux·Windows 등을 포함한 amd64 환경에 레지스터를 사용하는 새 호출 규약을 도입했습니다. 따라서 대상 Go 버전에 따라 인자를 어디에서 읽을지 달라집니다. Go 1.17 릴리스 노트
플러그인은 CALL 앞의 명령어를 거슬러 읽으며 인자 위치에 어떤 값이 설정되는지 분석합니다. 상수를 넣는 명령, 레지스터 사이의 복사, 데이터 주소를 계산하는 명령을 따라가 복원 가능한 값을 찾습니다. 호출 지점 분석 구현
BRICKSTORM의 파일 관련 호출
그림 2. BRICKSTORM의 파일 API 호출 전 인자 설정. 출처: 논문 원문 · Figure 5 · PDF 9쪽.
이 BRICKSTORM 샘플은 Go 1.16.3으로 컴파일됐으며, 인자를 스택에 배치하는 호출 규약을 사용합니다. 그림에서 os.ReadFile, os.Remove, os.WriteFile 호출 앞의 lea는 주소를 계산합니다. 뒤의 mov는 포인터와 길이를 스택에 설정합니다.
앞서 문자열의 범위를 확인하는 데 사용한 포인터와 길이가, 이 그림에서는 파일 API에 전달할 경로를 나타냅니다.
여기서 복원한 것은 해당 경로를 인자로 설정하는 코드입니다. 실제 호출이 실행됐고 파일 쓰기가 성공했는지는 파일 시스템이나 다른 실행 기록으로 확인해야 합니다.
5. go_goroutines는 스택에서 연결된 객체를 따라간다
goroutine은 Go 런타임이 관리하는 실행 단위입니다. 런타임의 g 구조에는 상태와 스택에 관한 정보가 있으며, allgs에는 goroutine 구조를 가리키는 참조들이 관리됩니다. Go 1.25.5 런타임 구조 · allgs 관리 코드
go_goroutines는 이 구조와 저장된 PC·SP, 함수별 스택 정보를 이용해 프레임을 따라갑니다. PC는 코드 위치를, SP는 스택 위치를 가리킵니다. 이후 프레임의 인자를 해석하고, 포인터가 가리키는 객체의 필드를 읽습니다. 공개 goroutine 분석 코드
여기서는 보존된 스택 프레임에 연결된 값을 읽습니다. 서버 주소가 연결 객체의 설정에 있는지, 요청 버퍼에 있는지를 확인하면 그 용도를 해석하는 데 도움이 됩니다.
Pantegana의 HTTP 연결 상태
Pantegana는 연구진이 소스를 컴파일해 통제된 로컬 네트워크에서 실행한 사례입니다. 저자들은 대기 상태인 goroutine의 표준 라이브러리 스택 프레임을 통해 HTTP 연결 객체의 설정과 버퍼를 복구했습니다.논문 §4.4
그림 3. Pantegana의 HTTP/TLS 관련 goroutine에서 복구한 정보. 출처: 논문 원문 · Table 5 · PDF 10쪽. 주소·토큰은 실험 환경의 값입니다.
표에는 연결 대상과 TLS 상태, 요청·응답 버퍼가 정리돼 있습니다. persistConn은 Go의 HTTP transport에서 연결, 읽기·쓰기 버퍼, TLS 상태를 관리하는 구조입니다. Go 1.25.5 HTTP transport 소스
이 표에서는 goroutine → 함수 프레임 → 객체 필드 → 값 순서로 읽어 보았습니다. bw.buf와 br.buf를 구분하면 요청과 응답이 각각 어떤 버퍼에 남아 있는지 알 수 있습니다. 저는 문자열의 내용과 함께 그 값이 어느 객체의 어떤 필드에서 나왔는지도 확인할 수 있다는 점에 주목했습니다.
HTTPS 통신에서도 애플리케이션이 요청을 만들고 응답을 처리할 때는 읽을 수 있는 형태의 데이터가 필요합니다. 메모리의 HTTP 버퍼를 읽는 것과 네트워크의 TLS 암호문을 해독하는 것은 구분해야 합니다.
또한 요청 내용이 버퍼에 있다는 사실만으로 서버의 수신을 확정할 수는 없습니다. 버퍼의 유효 범위와 요청·응답의 대응을 함께 확인해야 합니다.
스택 복원 결과를 해석할 때
스택을 읽을 때는 goroutine의 상태와 ABI를 함께 봐야 합니다. g.sched의 저장된 PC·SP가 모든 goroutine에서 캡처 순간의 CPU 상태와 같은 것은 아닙니다. 레지스터 인자를 위해 예약된 스택 공간에도 유효한 값이 반드시 들어 있는 것은 아닙니다. Go 1.25.5 스택 추적 구현 · ABI의 인자 저장 공간
6. 실험 결과는 어디까지 보여주는가
저자들은 통제된 환경에서 수집한 메모리 이미지로 다음 네 사례를 평가했습니다. 아래 표는 각 사례의 실험 환경을 정리한 것입니다. 분석 대상 아키텍처는 x86-64입니다. 논문 §4·§5
| 사례 |
OS |
Go 버전 |
| Screenshotter |
Linux |
1.24.10 |
| Obscura |
Windows |
1.15 |
| BRICKSTORM |
Linux |
1.16.3 |
| Pantegana |
Linux |
1.25.5 |
표에 포함된 Obscura 사례에서는 Curve25519 공개키를 복구했습니다. 공개키 복구가 피해 파일의 복호화 성공을 뜻하는 것은 아닙니다. 논문 Table 1
Pantegana에서는 goroutine 31개를 식별했고, 그중 23개를 valid로 표시했으며, 13개를 HTTP/TLS 통신과 연결했습니다. 논문 §4.4
여기서 유효성 검사는 스택 경계, 상태 값, goroutine 식별자 등을 통해 후보 구조가 타당한지 확인하는 절차를 말합니다. 논문 §3.4
이 수치는 해당 사례에서 분석한 대상의 범위를 보여줍니다. 복구 성공률을 평가하려면 실제로 존재한 대상과 비교해 누락과 잘못된 식별을 확인해야 합니다.
저자들은 현재 분석이 호출 지점과 goroutine 프레임의 인자 복구에 초점을 둔다고 설명합니다. 지역변수와 반환값까지 데이터 흐름을 추적하는 것은 향후 과제로 남아 있습니다. 논문 §5
공개 구현의 Linux 분석에는 메모리 이미지와 커널 심볼이 필요합니다. 여기서 커널 심볼은 Go 바이너리의 디버깅 심볼과 별개입니다. 함수 시그니처 자료 등 플러그인별 추가 입력과 준비 순서는 공개 저장소에서 확인할 수 있습니다.
추가로 궁금한 것은 메모리 일부만 확보했을 때의 분석 범위입니다. 문자열 자체를 읽을 수 있는 범위와 그 값을 객체·스택에 연결할 수 있는 범위를 따로 확인해 보고 싶습니다.
마치며
이번 논문을 읽고, 문자열을 찾은 뒤에는 그 값이 어디에 있었고 어떤 코드나 객체와 연결되는지를 더 살펴봐야겠다고 생각했습니다. Screenshotter에서는 헤더와 데이터의 위치를, BRICKSTORM에서는 인자 설정 코드를, Pantegana에서는 버퍼 필드를 살펴봤습니다. 앞으로 비슷한 분석 결과를 읽을 때도 문자열의 내용과 그 값을 설명하는 근거를 같이 확인해 보고 싶습니다.
참고 자료