BPFDoor의 원리와 구현
BPFDoor는 Attacker에게 패킷을 받아, Target 시스템 내부를 공격 할 수 있는 방법이다. 하지만 미리 Target 시스템에 Door 역할을 수행할 프로그램을 심어두는 사전 조건이 필요하다.
이 방법을 통해서 Port listen을 숨길 수 있으며, OSI 3-4계층에서 작동하는 방화벽을 무시할 수 있다.
SKT 해킹사태가 바로 BPFDoor로 야기된 사건으로서, 이번 포스팅에서 BPFDoor의 원리와 구현 방식에 대해서 설명한다.
SKT 해킹에 中해커 주특기 백도어 악성코드…"주체 단정 어려워" | 연합뉴스
(서울=연합뉴스) 조성미 기자 = SK텔레콤[017670]의 가입자 유심(USIM) 정보를 탈취한 사이버 공격에서 중국 해커 그룹이 주로 사용하...
www.yna.co.kr
실습과정을 위해서 아래와 같은 준비 과정이 필요하다.
- Target(아래 중 택1)
- Scapy
- C++ Build
- Attacker(단일 옵션)
- Python Build + Scapy
Debian 계열 OS에서 실험했으며, Target 옵션중 어떤것을 사용해도 무방하지만 굳이 C++을 추가한 이유는 내부적으로 어떻게 동작하는지 이해를 돕기 위함이다.
실습
Target System
Scapy는 사용자가 패킷을 전송하고 스니핑 하고 분석하고 조작할 수 있게 해주는 파이썬 프로그램으로서 BPF 필터를 사용할 수 있다.
Scapy
sniff(filter="tcp and port 9090", prn=lambda x: print(f"{x[IP].src}:{x[TCP].sport} -> {x[IP].dst}:{x[TCP].dport} {x[TCP].flags} : {x.getlayer(Raw).load if x.haslayer(Raw) else ''}"))
C++을 통해 실제 Socket을 열고 2계층에서 데이터를 수신받아보자.
다만 본래 BPFDoor는 커널단에서 동작하므로 프로세스에 감지되지 않는지만, 해당 실습에서 구축한 BPF Door는 프로세스단에서 감지된다는 차이점이 있다.
C++ Build
#include <iostream>
#include <vector>
#include <cstring> // For memcpy, memset
#include <csignal> // For signal handling (Ctrl+C)
#include <iomanip> // For std::hex, std::setw, std::setfill
// Linux/POSIX Headers
#include <unistd.h> // For close()
#include <sys/socket.h> // For socket(), recvfrom()
#include <netinet/in.h> // For IPPROTO_TCP, IPPROTO_UDP, etc., sockaddr_in
#include <netinet/ip.h> // For struct iphdr (use iphdr instead of ip)
#include <netinet/tcp.h> // For struct tcphdr
#include <netinet/udp.h> // For struct udphdr
#include <netinet/if_ether.h> // For ETH_P_IP, struct ethhdr
#include <arpa/inet.h> // For inet_ntoa(), htons(), ntohs()
// Global flag for signal handling (graceful shutdown)
volatile sig_atomic_t stop_capture = 0;
void signalHandler(int signum) {
(void)signum; // Unused parameter
std::cout << "\nInterrupt signal received. Stopping capture..." << std::endl;
stop_capture = 1;
}
// Helper function to print payload data in hex and ASCII
void printPayload(const unsigned char* payload, int len) {
if (len <= 0) {
std::cout << " [No Payload Data]" << std::endl;
return;
}
std::cout << " [Payload Data (" << len << " bytes)]:" << std::endl;
const int bytes_per_line = 16;
for (int i = 0; i < len; i += bytes_per_line) {
// Print Hex offset
std::cout << " 0x" << std::hex << std::setw(4) << std::setfill('0') << i << ": ";
// Print Hex bytes
for (int j = 0; j < bytes_per_line; ++j) {
if (i + j < len) {
std::cout << std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(payload[i + j]) << " ";
} else {
std::cout << " "; // Pad if line is short
}
}
std::cout << " ";
// Print ASCII representation
for (int j = 0; j < bytes_per_line; ++j) {
if (i + j < len) {
char c = payload[i + j];
if (isprint(c)) {
std::cout << c;
} else {
std::cout << "."; // Print dot for non-printable characters
}
}
}
std::cout << std::endl;
}
std::cout << std::dec; // Reset base to decimal for other output
}
int capturePackets() {
// --- Configuration ---
bool show_tcp = true;
bool show_udp = true;
bool show_icmp = true;
bool show_others = false; // Usually less interesting unless debugging
bool filter_loopback = true; // Option to ignore 127.0.0.1 traffic
// --- Socket Creation ---
// AF_PACKET: Low-level packet interface
// SOCK_RAW: Raw network protocol access
// htons(ETH_P_ALL): Capture all Ethernet protocols (we'll filter IP later)
int sockfd = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
if (sockfd < 0) {
perror("ERROR: Socket creation failed");
return 1;
}
std::cout << "Raw socket created successfully." << std::endl;
// --- Buffer for incoming data ---
// Max possible Ethernet frame size is typically around 1518,
// but jumbo frames exist. 65536 is common for packet capture.
std::vector<unsigned char> buffer(65536);
std::cout << "Starting packet capture... (Press Ctrl+C to stop)" << std::endl;
// --- Main Capture Loop ---
while (!stop_capture) {
struct sockaddr saddr; // Generic socket address structure
socklen_t saddr_len = sizeof(saddr);
// Receive a packet
ssize_t data_size = recvfrom(sockfd, buffer.data(), buffer.size(), 0, &saddr, &saddr_len);
if (data_size < 0) {
// Check if the error was due to interruption (Ctrl+C)
if (errno == EINTR && stop_capture) {
break; // Exit loop gracefully
}
perror("ERROR: Packet recvfrom failed");
continue; // Try receiving next packet
}
// --- 1. Ethernet Header Parsing ---
if (data_size < sizeof(struct ethhdr)) {
// std::cerr << "WARN: Received packet smaller than Ethernet header" << std::endl;
continue; // Packet too small
}
struct ethhdr* eth_header = (struct ethhdr*)buffer.data();
// Check if it's an IP packet (EtherType 0x0800)
if (ntohs(eth_header->h_proto) != ETH_P_IP) {
continue; // Skip non-IP packets
}
// --- 2. IP Header Parsing ---
size_t ip_header_offset = sizeof(struct ethhdr);
if (data_size < ip_header_offset + sizeof(struct iphdr)) {
// std::cerr << "WARN: Received packet smaller than Ethernet + minimal IP header" << std::endl;
continue; // Packet too small
}
struct iphdr* ip_header = (struct iphdr*)(buffer.data() + ip_header_offset);
// Calculate IP header length (ihl field is in 4-byte words)
unsigned int ip_header_len = ip_header->ihl * 4;
if (ip_header_len < sizeof(struct iphdr)) { // Minimum IP header size check
// std::cerr << "WARN: Invalid IP header length: " << ip_header_len << " bytes" << std::endl;
continue;
}
// Check if received data size is sufficient for the declared IP header length
if (data_size < ip_header_offset + ip_header_len) {
// std::cerr << "WARN: Received data size (" << data_size << ") smaller than required for IP header (" << ip_header_offset + ip_header_len << ")" << std::endl;
continue;
}
// --- Filtering ---
int protocol = ip_header->protocol;
bool show_packet = false;
// Loopback filtering
struct in_addr src_ip = { ip_header->saddr };
struct in_addr dst_ip = { ip_header->daddr };
if (filter_loopback && strcmp(inet_ntoa(src_ip), "127.0.0.1") == 0 && strcmp(inet_ntoa(dst_ip), "127.0.0.1") == 0) {
continue;
}
switch (protocol) {
case IPPROTO_TCP: show_packet = show_tcp; break;
case IPPROTO_UDP: show_packet = show_udp; break;
case IPPROTO_ICMP: show_packet = show_icmp; break;
default: show_packet = show_others; break;
}
if (!show_packet) {
continue; // Skip packet based on filter settings
}
// --- Print Basic Packet Info ---
std::cout << "----------------------------------------" << std::endl;
std::cout << "Packet (" << data_size << " bytes): ";
std::cout << inet_ntoa(src_ip) << " -> " << inet_ntoa(dst_ip);
std::cout << ", Proto: ";
// --- 3. Transport Layer Header Parsing & Payload ---
size_t transport_header_offset = ip_header_offset + ip_header_len;
unsigned char* payload_data = buffer.data() + transport_header_offset; // Initial payload pointer
int payload_len = data_size - transport_header_offset; // Initial payload length
// Protocol-specific handling
if (protocol == IPPROTO_TCP) {
std::cout << "TCP";
if (data_size < transport_header_offset + sizeof(struct tcphdr)) {
std::cout << " [WARN: Packet too small for TCP header]" << std::endl;
continue;
}
struct tcphdr* tcp_header = (struct tcphdr*)(buffer.data() + transport_header_offset);
unsigned int tcp_header_len = tcp_header->doff * 4;
if (tcp_header_len < sizeof(struct tcphdr)) {
std::cout << " [WARN: Invalid TCP header length: " << tcp_header_len << " bytes]" << std::endl;
continue;
}
if (data_size < transport_header_offset + tcp_header_len) {
std::cout << " [WARN: Packet too small for full TCP header]" << std::endl;
continue;
}
std::cout << ", Port: " << ntohs(tcp_header->source) << " -> " << ntohs(tcp_header->dest);
payload_data = buffer.data() + transport_header_offset + tcp_header_len;
payload_len = data_size - (transport_header_offset + tcp_header_len);
printPayload(payload_data, payload_len);
} else if (protocol == IPPROTO_UDP) {
std::cout << "UDP";
if (data_size < transport_header_offset + sizeof(struct udphdr)) {
std::cout << " [WARN: Packet too small for UDP header]" << std::endl;
continue;
}
struct udphdr* udp_header = (struct udphdr*)(buffer.data() + transport_header_offset);
std::cout << ", Port: " << ntohs(udp_header->source) << " -> " << ntohs(udp_header->dest);
// UDP header length field includes header (8 bytes) + data
int udp_total_len = ntohs(udp_header->len);
payload_data = buffer.data() + transport_header_offset + sizeof(struct udphdr);
payload_len = udp_total_len - sizeof(struct udphdr);
// Sanity check: ensure calculated payload doesn't exceed received data
if (payload_len < 0 || (transport_header_offset + sizeof(struct udphdr) + payload_len) > data_size) {
std::cout << " [WARN: UDP length mismatch or exceeds packet size]";
// Adjust payload_len based on actual received data if there's a discrepancy
payload_len = data_size - (transport_header_offset + sizeof(struct udphdr));
if (payload_len < 0) payload_len = 0;
}
printPayload(payload_data, payload_len);
} else if (protocol == IPPROTO_ICMP) {
std::cout << "ICMP";
// Could add ICMP header parsing here (type, code) if needed
std::cout << std::endl; // No ports for ICMP typically shown this way
// Payload for ICMP often starts right after the basic ICMP header (usually 8 bytes)
// You might want to parse type/code before printing payload
printPayload(payload_data, payload_len);
} else {
std::cout << "Other (" << protocol << ")" << std::endl;
printPayload(payload_data, payload_len);
}
} // End while loop
// --- Cleanup ---
std::cout << "Closing socket." << std::endl;
close(sockfd);
return 0;
}
int main() {
// Register signal handler for SIGINT (Ctrl+C)
signal(SIGINT, signalHandler);
// Note: Running this program requires root privileges (or CAP_NET_RAW capability)
// to create a raw socket.
if (geteuid() != 0) {
std::cerr << "ERROR: This program must be run as root or with CAP_NET_RAW capability." << std::endl;
return 1;
}
return capturePackets();
}
BPF에 대해 상세하게 확인하길 원한다면 해당 문서(#1, #2, #3, #4)를 참조하길 바란다.
위 코드는 Target에서 작동하는 코드로, 파싱을 위한 출력 로직이 복잡할 뿐이지 실질적으로 복잡한 부분은 없다.
일반적으로 TCP연결의 경우 아래와 같은 과정을 따르게 되는데, BPFDoor 공격은 이러한 bind, listen 등 TCP, UDP 프로토콜단의 모든 행위를 수행하지 않는다.
따라서 Target 코드 부분을 살펴보면 아래와 같이 간단하게 작성되어 있는데,
// open socket
int sockfd = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
// just recvfrom
ssize_t data_size = recvfrom(sockfd, buffer.data(), buffer.size(), 0, &saddr, &saddr_len);
저 코드로 인해 Target 프로그램은 data link 계층에서 frame 단위로 IP, Port를 무시한 채 모든 데이터를 수신받을 수 있다.
위와 같은 계념을 전제로 Scapy를 사용해 Attacker용 코드를 작성하여 Target 내부에서 패킷이 수신되는지 실습해 보자.
Attacker System
아래는 Attacker(Sender) 역할을 할 python 코드이며 Scapy가 사전에 설치되어 있어야 한다.
Python Build
import socket
from scapy.all import *
import time
import sys
def sendUDP(target_ip="172.17.0.2", target_port=9090):
"""Send a simple UDP message to the specified IP and port."""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
message = "this is UDP test!!!"
sock.sendto(message.encode('utf-8'), (target_ip, target_port))
print(f"[UDP Sent] Sent '{message}' to {target_ip}:{target_port}")
except socket.error as e:
print(f"[UDP Error] Socket error: {e}")
except Exception as e:
print(f"[UDP Error] Unexpected error: {e}")
finally:
if 'sock' in locals() and sock:
sock.close()
def sendTCP_scapy(target_ip="172.17.0.2", target_port=9090):
"""
Send a raw TCP packet (PSH+ACK) without TCP handshake using Scapy.
Useful for testing packet sniffers at L2/L3 level.
"""
try:
message = "TCP BPF Test!!!"
packet = IP(dst=target_ip)/TCP(dport=target_port, flags="PA")/Raw(load=message.encode('utf-8'))
send(packet, verbose=0)
print(f"[TCP Scapy Sent] Sent raw PSH+ACK packet with payload '{message}' to {target_ip}:{target_port}")
print(f" (Source Port: {packet[TCP].sport})")
except Exception as e:
print(f"[TCP Scapy Error] Unexpected error: {e}")
if __name__ == "__main__":
target_ip_address = sys.argv[1] if len(sys.argv) > 1 else "172.17.0.2"
udp_port = 9090
tcp_port = 9090
print(f"Target IP: {target_ip_address}")
print("Sending UDP packet...")
sendUDP(target_ip_address, udp_port)
time.sleep(1)
print("\nSending TCP packet using Scapy (PSH+ACK)...")
sendTCP_scapy(target_ip_address, tcp_port)
print("\nScript finished.")
실습 결과
Target 코드중 하나를 선택해서 실행하고, Attacker 코드를 실행하면 아래와 같은 결과를 확인할 수 있다.
UDP, TCP 패킷이 모두 잘 송신되고, 수신되는 것을 확인할 수 있다.
Ignore Port
BPFDoor는 2계층에서 수행되는 공격으로, listen() 함수를 수행하지 않으므로 포트가 열리지 않은 상태로 수신된다.
Target 에서 port open 없이 수신되는 메세지
Raw socket created successfully.
Starting packet capture... (Press Ctrl+C to stop)
----------------------------------------
Packet (61 bytes): 172.17.0.1 -> 172.17.0.2, Proto: UDP, Port: 44825 -> 9090 [Payload Data (19 bytes)]:
0x0000: 74 68 69 73 20 69 73 20 55 44 50 20 74 65 73 74 this is UDP test
0x0010: 21 21 21 !!!
----------------------------------------
Packet (89 bytes): 172.17.0.2 -> 172.17.0.1, Proto: ICMP
[Payload Data (55 bytes)]:
0x0000: 03 03 91 2e 00 00 00 00 45 00 00 2f eb 1c 40 00 ........E../..@.
0x0010: 40 11 f7 7b ac 11 00 01 ac 11 00 02 af 19 23 82 @..{..........#.
0x0020: 00 1b 58 52 74 68 69 73 20 69 73 20 55 44 50 20 ..XRthis is UDP
0x0030: 74 65 73 74 21 21 21 test!!!
----------------------------------------
Packet (69 bytes): 172.17.0.1 -> 172.17.0.2, Proto: TCP, Port: 20 -> 9090 [Payload Data (15 bytes)]:
0x0000: 54 43 50 20 42 50 46 20 54 65 73 74 21 21 21 TCP BPF Test!!!
또한 받은 UDP 패킷의 반환으로 전달되는 ICMP 패킷 속 `03 03` 은 아래와 같은 의미를 지닌다.
즉 도착하지 않았고, 그 이유가 포트에 문제가 있음을 나타내고 있다. 하지만 BPFDoor를 사용할 때 이딴건 전혀 상관없다. 중요한건 우리가 Attacker 코드에서 보낸 명령 메세지를 모두 받아볼 수 있고, 그렇게 받은 명령 메세지로 공격 행위를 수행할 수 있다는 점이다.
TCP 프로토콜 역시 아주 잘 받아지는 모습을 확인할 수 있으며, `ss -a | grep 9090` 명령어로 확인해 봐도 열린 포트는 존재하지 않다는 사실을 확인할 수 있다.
Ignore Firewall
또한 우리가 작성한 코드는 2계층에서 공격명령을 받아 수행하기에 3~4계층을 막는 iptables, UFW 로는 공격을 막을 수 없다.
실제로 아래와 같이 UFW를 통해서 9090 포트를 막았음에도
여전히 2계층에서 데이터를 수신받아 명령어를 처리할 수 있음을 알 수 있다.
결론
결론적으로, 작은 프로그램 하나(BPFDoor)를 물리적으로 시스템에 접근하여 관리자 권한 + 부팅시 자동 실행으로 삽입해 둔다면 시스템 내의 모든 데이터를 휘저으며 내 시스템인것 마냥 돌아다닐 수 있다.
또한 해당 예제에서는 작업관리자를 통해 BPFDoor가 작동하는것을 확인할 수 있지만, 사실 해커들이 운용하는 BPFDoor는 Kernel단에서 움직이기에 Process 작업관리자 따위로는 감지할 수 없다.
때문에 아래와 같은 사항을 명심하길 바란다.
시스템을 점검할 때 Port Listen, Established 여부와
방화벽 설정만으로 안전하다고 생각해서는 안된다.
해커는 항상 기상천외한 방법을 사용하여 침투한다.
이번 SKT에서 해킹당한 방법이 바로 이와 같은 BPFDoor 해킹 기법이다.
사건이 터진지 1주일이 지난 현재까지도 '피해 범위'를 파악하고 있지 못한데, 동작 원리를 보면 알 수 있듯. 내부 데이터가 암호화되어 있지 않고, Target 프로그램이 정교하게 작성되어 있다면 사실상 전부 털린것과 마찬가지이다.
위에서 설명했듯 반드시 프로그램을 내장시켜야 하기에 어떻게 해당 프로그램이 침투했는지 밝히는것도 난제이다. 내부자 스파이 직원이 있을수도 있고, 내부망을 타고 들어왔을수도 있는 등, 여러가지 가능성을 고려해서 조사해야 하기에 상당한 시일이 소요될 것으로 추정된다.