위협 인텔리전스 팀이 무언가를 스캔하기 전에, 이미 다른 당사자들이 보유한 기록만으로도 도메인에 대해 놀라울 정도로 많은 것을 알아낼 수 있다. 인증서 로그는 호스트명을 나열하고, 등록 데이터는 날짜와 네임서버를 제공하며, 웹 아카이브는 과거에 무엇을 게시했는지 보여준다. 보안 팀에게 이 수동적 단계는 작업 중 가장 비용이 적게 들고 가장 조용한 부분이며, 공격자도 똑같이 볼 수 있는 것을 보여준다.
이 가이드는 주요 수동적 수집 소스, 간단하게 테스트된 수집기, 그리고 “수동적”이라는 표현이 더 이상 성립하지 않는 지점을 다룬다. 자신의 조직이나 승인된 범위 내에서 사용하라.
수동적이라는 것의 의미
수동적 수집이란 요청이 이미 해당 데이터를 보유한 제3자에게 전달되고, 대상의 서버로는 절대 전달되지 않는 것을 의미한다. MITRE ATT&CK는 Reconnaissance 항목에서 공격자 버전을 설명하는데, “자유롭게 이용 가능한 기술 데이터베이스”를 검색하는 것(T1596)이 포함되며, DNS 및 passive DNS, WHOIS, 디지털 인증서, CDN, 스캔 데이터베이스에 대한 항목이 있다.
MITRE는 또한 방어자가 왜 이를 신경 써야 하는지 설명한다. 이 활동은 대상 조직의 가시성 밖에서 일어날 수 있어 탐지하기 어려우며, 예방적 통제로 쉽게 완화할 수 없다고 말한다. 누군가가 공개 로그를 읽는 것을 차단할 수는 없다. 할 수 있는 것은 먼저 들여다보고 로그가 드러내는 것을 줄이는 것이다.
확인할 가치가 있는 소스들
| 소스 | 얻을 수 있는 것 | 한계 |
|---|---|---|
| Certificate Transparency 로그 | 발급된 TLS 인증서의 호스트명 | 이름만 보여줄 뿐 활성 여부는 알 수 없음; 와일드카드는 서브도메인을 숨김 |
| RDAP | 등록기관, 날짜, 네임서버 | 개인 데이터는 보통 가려져 있음 |
| 웹 아카이브 | 과거 페이지, URL, 호스트 | 캡처된 것만, 종종 수년 전 것 |
| Passive DNS | 이름이 어떤 IP로 해석되었는지, 언제 | 대부분 상업용이며 커버리지가 다양함 |
| 인터넷 스캔 데이터베이스 | 다른 누군가가 본 열린 포트와 배너 | 스냅샷이며 오래된 정보일 수 있음 |
Certificate Transparency는 가장 풍부한 무료 소스다. RFC 9162는 누구나 인증기관을 감사할 수 있도록 TLS 인증서의 “존재를 공개적으로 기록하는” 프로토콜을 설명하며, 로그는 추가 전용(append-only)이다. 이는 staging.internal-tool.example.com과 같은 인증서가 해당 호스트가 단 일주일만 존재했더라도 계속 보인다는 것을 의미한다.
RDAP는 WHOIS를 대체하는 구조화된 방식이다. ICANN은 2025년 1월 28일부터 일반 최상위 도메인(generic top-level domain) 레지스트리와 등록기관이 RDAP를 제공해야 하며 더 이상 WHOIS를 제공할 필요가 없다고 밝히고 있으므로, 새로운 도구는 이를 기본으로 삼아야 한다.
OSINT가 무엇이며 그 법적 경계가 어디에 있는지에 대한 우리 가이드는 더 넓은 분야를 다루며, 이 가이드에서 다루지 않는 사람에 대한 소스도 포함한다.
작은 수동적 수집기
아래 스크립트는 도메인에 대해 세 개의 제3자 서비스에 질의하며, 도메인 자체에는 절대 연결하지 않는다. Python 3.9 이상에서 실행되며 requests만 필요하다. 소스가 다운되어도 계속 진행하는데, 무료 공개 서비스는 종종 다운되기 때문이다. 이 글을 테스트하는 동안 crt.sh와 Wayback Machine은 각각 몇 분씩 오류와 타임아웃을 반환했다.
from __future__ import annotations
import sys
import time
import requests
TIMEOUT = (10, 60)
HEADERS = {"User-Agent": "passive-recon-demo/1.0"}
def get_json(url, params=None):
"""Every request goes to a third-party source, never to the target itself."""
for attempt in range(3):
try:
r = requests.get(url, params=params, headers=HEADERS, timeout=TIMEOUT)
r.raise_for_status()
return r.json()
except requests.RequestException:
if attempt == 2:
raise
time.sleep(10 * (attempt + 1)) # free public services go down often
def in_scope(host, domain):
"""The domain or a real subdomain of it; CT names also hold labels and lookalikes."""
return " " not in host and (host == domain or host.endswith("." + domain))
def certificate_names(domain):
"""Hostnames from Certificate Transparency logs, through crt.sh."""
rows = get_json("https://crt.sh/", {"q": f"%.{domain}", "output": "json"})
names = {n.strip().lower() for row in rows for n in row["name_value"].splitlines()}
return sorted(n for n in names if "*" not in n and in_scope(n, domain))
def archived_hosts(domain, limit=500):
"""Hostnames the Wayback Machine has captured, without loading any live page."""
rows = get_json("https://web.archive.org/cdx/search/cdx", {
"url": domain, "matchType": "domain", "output": "json",
"fl": "original", "limit": limit,
})
urls = [row[0] for row in rows[1:]] # the first row is the header
hosts = {u.split("//")[-1].split("/")[0].split(":")[0].lower() for u in urls}
return sorted(h for h in hosts if in_scope(h, domain))
def registration(domain):
"""Registrar and key dates from RDAP, the structured successor to WHOIS."""
data = get_json(f"https://rdap.org/domain/{domain}")
dates = {e["eventAction"]: e["eventDate"][:10] for e in data.get("events", [])}
return {"registered": dates.get("registration"), "expires": dates.get("expiration"),
"nameservers": sorted(n["ldhName"].lower() for n in data.get("nameservers", []))}
def collect(domain):
sources = {"certificates": certificate_names, "archive": archived_hosts,
"rdap": registration}
found, failed = {}, {}
for name, fetch in sources.items():
try:
found[name] = fetch(domain)
except (requests.RequestException, ValueError, KeyError) as exc:
failed[name] = type(exc).__name__ # a source being down is a result too
return found, failed
if __name__ == "__main__":
domain = sys.argv[1] if len(sys.argv) > 1 else "example.com"
found, failed = collect(domain)
hosts = set(found.get("certificates", [])) | set(found.get("archive", []))
only_ct = set(found.get("certificates", [])) - set(found.get("archive", []))
only_archive = set(found.get("archive", [])) - set(found.get("certificates", []))
print(f"{domain}: {len(hosts)} hostnames seen by third parties")
print(f" in certificates only: {sorted(only_ct)[:6]}")
print(f" in the archive only: {sorted(only_archive)[:6]}")
print(f" registration: {found.get('rdap')}")
print(f" sources unavailable: {failed or 'none'}")
example.com에 대한 출력:
example.com: 7 hostnames seen by third parties
in certificates only: ['dev.example.com', 'emudhra-lab.example.com', 'm.example.com', 'products.example.com', 'support.example.com']
in the archive only: []
registration: {'registered': '1995-08-14', 'expires': '2027-08-13', 'nameservers': ['elliott.ns.cloudflare.com', 'hera.ns.cloudflare.com']}
sources unavailable: none
위 코드의 두 가지 세부 사항은 테스트를 통해 나온 것이다. 인증서 로그에는 호스트명 이상의 것이 담겨 있다: 초기 버전은 단순 접미사 검사를 사용했는데, 그 때문에 공백이 포함된 인증서 라벨과 유사 이름인 m.testexample.com이 통과되었다. 이것이 in_scope가 전체 서브도메인을 일치시키는 이유다. 또 다른 세부 사항은 각 소스가 서로 다른 것을 보여주므로 하나만 신뢰하지 말고 서로 비교해야 한다는 점이다. 오래된 아카이브에만 있는 호스트명은 이미 사라졌을 수 있고, 최근 인증서에만 있는 호스트명은 새로 생긴 것일 수 있다.
수동적인 상태가 끝나는 지점
이 경계는 보이는 것보다 넘기 쉽다.
- 찾은 URL을 열어보는 것. 결과에서 찾은 활성 호스트명을 방문하면 자신의 IP에서 대상에게 요청이 전송된다.
- 라이브 리소스가 있는 아카이브 페이지. 저장된 페이지가 여전히 원본 사이트에서 이미지나 스크립트를 로드하려 시도할 수 있다. 외부 로딩을 비활성화하거나, 원시 응답을 읽어라.
- DNS 조회. 리졸버를 통한 질의는 대상의 권한 있는 네임서버에 도달할 수 있다. 과거 passive DNS 데이터는 그렇지 않다.
- 질의 내용은 소스에게 보인다. 질의하는 서비스는 당신이 무엇을 보고 있는지 알게 된다. 민감한 조사라면 이것이 무엇을 드러내는지 고려하고, 해당 서비스의 약관과 속도 제한을 읽어라.
결과는 단서로 취급하라. 기록은 오래되고, 인증서는 호스트보다 오래 남으며, 공유 호스팅은 IP 이력을 오도할 수 있다.
결과를 가지고 할 일
방어자에게 이 결과는 외부 세계가 이미 볼 수 있는 것의 목록이다. 합리적인 루틴은 다음과 같다:
- 자산 인벤토리와 대조하라. 소유자를 매칭할 수 없는 호스트명이 바로 발견 사항이다.
- 이름이 무엇을 드러내는지 확인하라. 공개 로그에 있는
vpn-old나jenkins-test같은 이름은 공격자에게 도움이 된다. 내부 명명에는 적절한 경우 와일드카드 인증서를 고려하되, 그 자체의 위험도 함께 따져봐야 한다. - 반복하고, 새로운 것에 대해 경고하라. 지속적인 외부 공격 표면 모니터링에 대한 우리 가이드는 이를 소유자가 있는 diff로 바꾸는 방법을 보여준다.
- 능동적 점검으로의 전환은 신중하게 하라. 자신의 자산은 직접 검증하라. 조직 외부의 어떤 것이든 먼저 승인을 받아라. 위협 조사에서 의심스러운 사이트가 특정 국가의 실제 방문자에게 무엇을 보여주는지 확인해야 할 때, 그것은 자체 규칙을 가진 능동적 작업이며, 위협 인텔리전스를 위한 레지덴셜 프록시에서 다룬다.
결론
수동적 수집은 거의 비용을 들이지 않고, 경보를 울리지 않으면서도 도메인에 대한 유용한 그림을 제공한다. 인증서 로그, RDAP, 아카이브부터 시작하고, 서로 비교하며, 발견한 것을 단서로 취급하고, 자신의 작업이 수동적이지 않게 되는 순간을 솔직하게 인정하라. 다른 누군가가 할 수 있는 일이므로, 먼저 자기 자신에게 실행해보라.
출처 및 참고자료
- MITRE ATT&CK, Reconnaissance (TA0043): attack.mitre.org
- MITRE ATT&CK, Search Open Technical Databases (T1596): attack.mitre.org
- RFC 9162, Certificate Transparency Version 2.0: rfc-editor.org
- Certificate Transparency, How CT works: certificate.transparency.dev
- RFC 9083, JSON Responses for RDAP: rfc-editor.org
- ICANN, Registration Data Access Protocol: icann.org
- Internet Archive, Wayback CDX Server API: github.com/internetarchive/wayback