Step 315. 실서비스 정찰 — 공격 표면 매핑

Step 315. 실서비스 정찰 — 공격 표면 매핑

Level 4 — 버그바운티 | 난이도 ★★★☆☆ | 예상 소요 시간 3시간

전제: Step 171(서브도메인 열거), Step 314(스코프와 작전 명세서)를 마쳤다. 파이썬 requests를 쓸 수 있다.

  • 준비물: 파이썬 3, requests 패키지, 이 챕터의 모사 타깃 app.py (Flask).
  • ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
  • 주의: 정찰도 스코프 안에서만 합법입니다. 이 챕터에서 실제 명령은 로컬 모사 타깃(127.0.0.1)에 대해서만 실행하고, 실서비스 대상의 결과는 전부 화면 예시입니다.

버그바운티의 승부는 남들이 안 보는 자산을 찾는 데서 갈립니다. 기업의 실제 공격 표면은 메인 사이트보다 훨씬 넓습니다 — 개발 서버, 스테이징 환경, 오래된 API, 잊힌 관리자 페이지. 오늘은 이 자산들을 수집하고 분류해 "공격 표면 지도"를 만듭니다. 원리는 Step 171에서 배운 것이고, 오늘은 그 체인 전체를 실전 순서대로 돌려 봅니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • 수집 → 생존 확인 → 분류 → 지도의 정찰 체인 4단계를 순서대로 실행한다
  • subfinder/amass 출력을 합치고 중복을 제거하는 파이프라인을 설명한다
  • httpx 역할(상태 코드·타이틀·기술 스택 확인)을 파이썬으로 재현한다
  • 자바스크립트 파일에서 숨겨진 엔드포인트를 추출한다
  • 공격 표면 지도 문서를 완성하고 모든 수집이 스코프 안임을 검증한다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 파이썬 3, Flask(모사 타깃), requests
오늘의 도구 (실습) 사전 대입 열거 + 라이브 프로브 + JS 경로 추출 스크립트, (개념 소개) subfinder · amass · httpx · Wappalyzer
필요한 개념 수동/능동 열거(Step 171), 공격 표면, 핑거프린팅, 스코프(Step 314)
오늘의 산출물 공격표면지도.md 1부 — 라이브 호스트 + 기술 스택 + 우선 후보

2-1. 정찰 체인의 4단계

실전 정찰은 정해진 파이프라인을 따릅니다.

  1. 수집 — subfinder, amass 등으로 서브도메인 후보를 긁어모은다
  2. 생존 확인 — 그중 실제 응답하는 호스트만 추린다 (httpx의 역할)
  3. 분류 — 각 호스트의 기술 스택과 성격을 파악한다 (핑거프린팅)
  4. 지도화 — 우선 탐색 후보를 표시한 문서로 만든다

이 체인의 모든 단계는 스코프 안의 도메인에 대해서만 수행합니다. 수집 결과에 스코프 밖 이름이 섞이면 지도에서 지웁니다.

2-2. 수집 도구들 — subfinder와 amass

Step 171에서 본 subfinder는 수십 개 공개 출처(인증서 로그, 검색엔진, DNS 수집 서비스)를 합쳐 서브도메인을 냅니다. amass도 비슷한 일을 하지만 출처 구성이 달라, 둘을 함께 쓰면 서로 못 찾은 것을 메웁니다. 그래서 실전 표준은 합치기 → 중복 제거입니다.

# 화면 예시 — 실서비스 실행은 스코프 확인 후에만
subfinder -d target.com -o subs1.txt
amass enum -passive -d target.com -o subs2.txt
cat subs1.txt subs2.txt | sort -u > subs.txt

2-3. 생존 확인과 핑거프린팅 — httpx의 역할

수집 목록의 절반 이상은 죽은 이름입니다. httpx는 각 이름에 HTTP 요청을 보내 응답하는 것만 남기면서, 상태 코드·페이지 타이틀·서버 정보를 함께 출력합니다. 이 한 줄의 출력이 "어디를 먼저 볼까"의 재료입니다.

핑거프린팅(fingerprinting)은 응답 헤더와 페이지 내용으로 기술 스택을 알아내는 것입니다 — X-Powered-By: PHP/5.6 같은 헤더, Wappalyzer가 잡아내는 CMS·프레임워크 정보가 여기 속합니다. 낡은 기술 스택은 곧 "패치가 안 된 자산"의 후보입니다.

2-4. 자바스크립트 — 숨겨진 지도

프런트엔드 JS 파일은 엔드포인트의 목격담 모음입니다. 개발자가 지우지 않은 fetch('/api/internal/...') 한 줄이 미공개 API로 이어지는 일이 흔합니다. JS를 내려받아 /api/... 경로를 정규식으로 추출하는 것 — 오늘 실습하는 이 기법이 버그 헌터들의 기본기입니다.

2-5. "메인 사이트 말고" 원칙

수집량이 수백 개면 어디서부터 볼지 막힙니다. 거르는 원칙은 하나입니다 — 메인 사이트 말고. dev, staging, old, test, api, backup 같은 이름의 호스트가 우선 후보입니다. 메인 사이트는 전 세계 헌터가 이미 훑었지만, 잊힌 개발 서버에는 취약점이 남아 있습니다.


3. 따라 하기

3-1. 모사 타깃 기동

실전 도구 체인을 그대로 돌릴 로컬 타깃 lab.local을 만듭니다. 4개의 가상 호스트를 포트로 모사합니다 — www(5001), dev(5002), api(5003), old(5004). app.py:

import threading, time
from flask import Flask, request, jsonify, session, Response

USERS = {
    "alice": {"id": 1001, "pw": "alice-pass!", "email": "alice@lab.local",
              "phone": "010-****-1001", "addr": "서울시 강남구 (가공)"},
    "bob":   {"id": 1002, "pw": "bob-pass!",   "email": "bob@lab.local",
              "phone": "010-****-1002", "addr": "부산시 해운대구 (가공)"},
}

def make_www():
    app = Flask("www"); app.secret_key = "www-secret"
    @app.route("/")
    def index():
        return ("<html><head><title>LabShop - 쇼핑의 모든 것</title></head>"
                "<body><h1>LabShop</h1></body></html>")
    @app.route("/login", methods=["GET", "POST"])
    def login():
        if request.method == "GET":
            return "<html><head><title>로그인 - LabShop</title></head><body></body></html>"
        u, p = request.form.get("user",""), request.form.get("pw","")
        if u in USERS and USERS[u]["pw"] == p:
            session["user"] = u; return f"로그인 성공: {u}"
        return "로그인 실패", 401
    @app.route("/search")
    def search():
        q = request.args.get("q", "")
        return f"<html><head><title>검색 결과</title></head><body>'{q}': 0건</body></html>"
    return app

def make_dev():
    app = Flask("dev")
    @app.route("/")
    def index():
        r = Response("<html><head><title>LabShop Dev Server</title></head>"
                     "<body><h1>DEV</h1><script src='/static/app.js'></script>"
                     "</body></html>")
        r.headers["X-Environment"] = "development"; return r
    @app.route("/static/app.js")
    def js():
        return Response(
            "// dev 빌드nfetch('/api/v1/health');n"
            "fetch('/api/internal/metrics');n"
            "fetch('/api/v2/admin/export?fmt=csv');n",
            mimetype="application/javascript")
    @app.route("/admin")
    def admin():
        return "<html><head><title>관리자 콘솔(DEV)</title></head><body>Admin</body></html>"
    return app

def make_api():
    app = Flask("api"); app.secret_key = "api-secret"
    @app.route("/api/v1/health")
    def health():
        return jsonify({"status": "ok", "version": "1.4.2"})
    return app

def make_old():
    app = Flask("old")
    @app.route("/")
    def index():
        r = Response("<html><head><title>Old Shop (2019)</title></head><body></body></html>")
        r.headers["X-Powered-By"] = "PHP/5.6.40"; return r
    return app

SERVICES = [(make_www, 5001, "www.lab.local"), (make_dev, 5002, "dev.lab.local"),
            (make_api, 5003, "api.lab.local"), (make_old, 5004, "old.lab.local")]

if __name__ == "__main__":
    from werkzeug.serving import make_server
    for factory, port, name in SERVICES:
        srv = make_server("127.0.0.1", port, factory())
        threading.Thread(target=srv.serve_forever, daemon=True).start()
        print(f"[UP] {name} -> http://127.0.0.1:{port}")
    print("랩 기동 완료. Ctrl+C로 종료.")
    while True:
        time.sleep(1)

입력 (터미널 1):

python app.py

출력 (2026-09-09 실측):

[UP] www.lab.local -> http://127.0.0.1:5001
[UP] dev.lab.local -> http://127.0.0.1:5002
[UP] api.lab.local -> http://127.0.0.1:5003
[UP] old.lab.local -> http://127.0.0.1:5004
랩 기동 완료. Ctrl+C로 종료.

읽는 법: 이제 127.0.0.1에 4개의 "서브도메인"이 살아 있습니다. 실전에서 subfinder가 찾아주는 목록을, 랩에서는 우리가 직접 만든 것입니다. 앞으로의 명령은 전부 터미널 2에서 실행합니다.

3-2. 정찰 체인 실행 — 수집부터 지도까지

recon.py를 만듭니다. 체인 4단계가 순서대로 들어 있습니다.

import re, requests

# --- 1. 수집: 사전 대입 열거 (실전에선 subfinder+amass의 역할) ---
zone = {  # 모사 DNS — 실제라면 DNS가 대답하는 부분
    "www.lab.local": 5001, "mail.lab.local": None, "dev.lab.local": 5002,
    "api.lab.local": 5003, "blog.lab.local": None, "old.lab.local": 5004,
    "vpn.lab.local": None, "test.lab.local": None,
}
wordlist = ["www", "mail", "ftp", "dev", "api", "blog", "old", "vpn", "test", "shop"]
found = []
for w in wordlist:
    name = f"{w}.lab.local"
    if name in zone and zone[name]:
        found.append((name, zone[name]))
print(f"[1] 단어 {len(wordlist)}개 시도 -> {len(found)}개 발견")
for name, port in found:
    print(f"    {name:18s} -> 127.0.0.1:{port}")

# --- 2. 생존 확인: 라이브 호스트 프로브 (httpx의 역할) ---
print("n[2] 라이브 호스트 프로브 (상태/타이틀/기술 힌트)")
rows = []
for name, port in found:
    try:
        r = requests.get(f"http://127.0.0.1:{port}/", timeout=3)
        m = re.search(r"<title>(.*?)</title>", r.text, re.S)
        title = m.group(1) if m else "(없음)"
        tech = (r.headers.get("X-Powered-By") or r.headers.get("X-Environment")
                or r.headers.get("Server", ""))
        rows.append((name, port, r.status_code, title, tech))
        print(f'    http://127.0.0.1:{port} [{r.status_code}] "{title}" ({tech})')
    except requests.ConnectionError:
        print(f"    {name}: 접속 실패")

# --- 3. JS에서 숨은 엔드포인트 수집 ---
print("n[3] dev.lab.local의 app.js에서 API 경로 추출")
js = requests.get("http://127.0.0.1:5002/static/app.js", timeout=3).text
paths = sorted(set(re.findall(r"['"](/api/[^'"]+)['"]", js)))
for p in paths:
    print(f"    {p}")
print(f"    -> {len(paths)}개 경로 발견")

# --- 4. 우선 후보 표시 ("메인 사이트 말고" 원칙) ---
print("n[4] 우선 탐색 후보 (이름 규칙: dev/old/test/api)")
for name, port, code, title, tech in rows:
    tag = "*" if re.match(r"(dev|old|test|staging|api).", name) else " "
    print(f"  [{tag}] {name:18s} {title}")

입력 (터미널 2):

python recon.py

출력 (2026-09-09 실측):

[1] 단어 10개 시도 -> 4개 발견
    www.lab.local      -> 127.0.0.1:5001
    dev.lab.local      -> 127.0.0.1:5002
    api.lab.local      -> 127.0.0.1:5003
    old.lab.local      -> 127.0.0.1:5004

[2] 라이브 호스트 프로브 (상태/타이틀/기술 힌트)
    http://127.0.0.1:5001 [200] "LabShop - 쇼핑의 모든 것" (Werkzeug/3.1.8 Python/3.12.14)
    http://127.0.0.1:5002 [200] "LabShop Dev Server" (development)
    http://127.0.0.1:5003 [404] "404 Not Found" (Werkzeug/3.1.8 Python/3.12.14)
    http://127.0.0.1:5004 [200] "Old Shop (2019)" (PHP/5.6.40)

[3] dev.lab.local의 app.js에서 API 경로 추출
    /api/internal/metrics
    /api/v1/health
    /api/v2/admin/export?fmt=csv
    -> 3개 경로 발견

[4] 우선 탐색 후보 (이름 규칙: dev/old/test/api)
  [ ] www.lab.local      LabShop - 쇼핑의 모든 것
  [*] dev.lab.local      LabShop Dev Server
  [*] api.lab.local      404 Not Found
  [*] old.lab.local      Old Shop (2019)

읽는 법: [2]를 보세요 — api.lab.local은 루트 경로가 404지만 이것은 죽은 호스트가 아니라 API 전용 호스트의 정상 모습입니다. 상태 코드만 보고 버리면 안 됩니다. [3]에서는 dev 서버의 JS에서 /api/internal/metrics, /api/v2/admin/export처럼 페이지에 링크되지 않은 경로가 나왔습니다 — 이것이 "숨겨진 엔드포인트"입니다. [4]의 [*]가 내일부터 두드릴 문입니다.

3-3. 실전 도구 출력과 대조 — 화면 예시

우리 스크립트의 [2]가 실전에서는 이렇게 생겼습니다. 화면 예시 (가공 데이터):

화면 예시 (cat subs.txt | httpx -silent -title -tech-detect 실행 결과의 모양):
https://www.example-corp.com [200] [Example Corp - Official]
https://dev.example-corp.com [200] [Dev Environment] [Werkzeug]
https://old-shop.example-corp.com [200] [Shop 2019] [PHP/5.6]
https://api.example-corp.com [404] []

읽는 법: 형식이 우리 [2]와 같습니다 — 주소, 상태 코드, 타이틀, 기술. 도구가 다를 뿐 파이프라인의 뼈대는 방금 직접 만든 것과 동일합니다. 실제 실행은 스코프를 확인한 타깃에 대해서만 합니다.

3-4. 공격 표면 지도 작성

수집 결과를 문서로 만듭니다. 공격표면지도.md:

# 공격 표면 지도 — lab.local / 작성일: ____

### 라이브 호스트
| 호스트 | 상태 | 타이틀 | 기술 힌트 | 우선 |
|--------|------|--------|-----------|------|
| www.lab.local | 200 | LabShop 메인 | Werkzeug/3.1.8 | |
| dev.lab.local | 200 | Dev Server | development 환경 표시 | * |
| api.lab.local | 404(루트) | - | API 전용 추정 | * |
| old.lab.local | 200 | Old Shop | PHP/5.6.40 (낡음) | * |

### JS에서 발견한 숨은 경로 (dev.lab.local)
- /api/internal/metrics
- /api/v1/health
- /api/v2/admin/export?fmt=csv

### 스코프 검증
- 수집·프로브 대상 전부 lab.local 하위 (작전 명세서 IN 스코프) — 확인 완료

읽는 법: 맨 아래 "스코프 검증" 칸이 이 문서의 핵심입니다. 지도가 아무리 좋아도 스코프 밖 자산이 섞여 있으면 그 지도를 따라가는 순간 불법이 됩니다.


4. 미션과 연습문제

미션 — 공격 표면 지도 완성

  1. 3-1의 랩을 기동하고 3-2의 recon.py를 실행해 출력을 저장합니다
  2. app.pymake_dev()에 엔드포인트 1개를 추가하고(예: /backup), 단어 목록에도 반영해 정찰이 새 자산을 찾아내는지 확인합니다
  3. 3-4 형식의 공격표면지도.md를 완성합니다 — 라이브 호스트 표, 숨은 경로, 우선 후보, 스코프 검증 칸 전부 포함
  4. 우선 후보마다 "왜 우선인가"를 한 줄로 적습니다 (예: "dev — 개발 환경 표시 노출, 디버그 기능 가능성")

연습문제

문제 1. subfinder와 amass를 함께 쓰고 sort -u로 합치는 이유를 설명해 보세요.

문제 2. api.lab.local의 루트가 404인데도 "죽은 호스트"가 아니라고 판단한 근거를 두 가지 들어 보세요.

문제 3. 프런트엔드 JS 파일이 "숨겨진 지도"가 되는 이유를 개발 과정의 관점에서 설명해 보세요.

문제 4. "메인 사이트 말고" 원칙이 성립하는 이유를 경쟁과 관리 상태 두 관점에서 설명해 보세요.


5. 모범 답안과 완료 기준

미션 모범 답안

엔드포인트 추가 후 recon.py를 다시 돌리면 새 호스트나 경로가 지도에 나타나야 합니다. "우선 이유"의 예시:

dev.lab.local — 응답 헤더 X-Environment: development. 개발 서버가 외부에 열려 있음.
api.lab.local — 루트 404는 정상. JS에서 찾은 /api/* 경로가 실제 대답하는지 확인 필요.
old.lab.local — X-Powered-By: PHP/5.6.40. 지원 종료된 버전 = 알려진 취약점 무더기 가능.

검증하는 법: ① recon.py 출력이 저장됐는가. ② 지도 문서의 4개 칸이 채워졌는가. ③ 각 우선 후보에 근거 한 줄이 있는가. ④ 스코프 검증 칸이 있는가. 전부 ‘예’이면 완성입니다.

연습문제 해답

문제 1 해답. 두 도구는 수집 출처 구성이 달라 서로가 못 찾는 이름이 있습니다. 합치면 수집률이 올라가고, sort -u는 겹치는 이름을 정렬 후 중복 제거해 다음 단계(프로브)의 입력을 깨끗하게 만듭니다. "여러 출처를 합치고 중복을 지운다"는 정찰 수집의 표준 패턴입니다.

문제 2 해답. 첫째, 404는 "서버가 살아서 응답했다"는 뜻입니다 — 죽은 호스트는 접속 자체가 실패합니다. 둘째, API 호스트는 브라우저용 루트 페이지가 없는 것이 정상이며, 실제 기능은 /api/v1/health 같은 경로에 있습니다. 상태 코드가 아니라 "응답이 왔는가"가 생존의 기준입니다.

문제 3 해답. 개발자는 프런트엔드에서 API를 호출할 때 경로를 JS에 직접 적습니다. 기능이 폐기되거나 관리자 전용으로 바뀌어도 JS 속 호출 코드는 지워지지 않는 일이 잦고, 빌드/배포 과정에서 내부용 경로가 그대로 섞여 나갑니다. 서버 설정(접근 제어)이 완벽하지 않으면 이 경로가 그대로 열린 문이 됩니다.

문제 4 해답. 경쟁 관점: 메인 사이트는 모든 헌터가 먼저 보는 곳이라 쉬운 취약점은 이미 제보(중복)됐을 확률이 높습니다. 관리 관점: dev/staging/old 자산은 만들 때의 목적이 지나고 방치되어, 패치·접근 제어가 메인보다 느슨한 경우가 많습니다. "남들이 안 보고, 관리자도 잊은 곳"에 취약점이 남습니다.

완료 기준 체크리스트

  • [ ] 정찰 체인 4단계(수집→생존 확인→분류→지도화)를 순서대로 말할 수 있다
  • [ ] 로컬 랩에서 recon.py를 실행해 4개 호스트를 프로브했다
  • [ ] 404 응답이 "죽은 호스트"를 뜻하지 않음을 실측으로 확인했다
  • [ ] JS에서 숨은 엔드포인트 3개를 추출했다
  • [ ] 공격표면지도.md를 완성하고 스코프 검증 칸을 채웠다
  • [ ] 실전 도구(subfinder/httpx) 출력과 내 스크립트 출력의 대응 관계를 설명할 수 있다

6. 흔한 실수와 해결

벽 1. ModuleNotFoundError: No module named 'requests'

증상:

ModuleNotFoundError: No module named 'requests'

원인: HTTP 클라이언트 패키지가 없는 것입니다.
해결: pip install requests를 실행하세요. Flask도 없다면 pip install flask가 필요합니다.

벽 2. ConnectionRefusedError로 전부 실패한다

증상:

requests.exceptions.ConnectionError: ... [Errno 10061] 대상 컴퓨터에서 연결을 거부했으므로 연결하지 못했습니다

원인: app.py(터미널 1)가 실행 중이 아니거나, 이미 종료된 것입니다.
해결: 터미널 1에서 python app.py[UP] 4줄을 출력하며 살아 있는지 확인하세요. 한글 윈도우는 위처럼 한글 오류가 뜰 수 있습니다.

벽 3. 수집량이 많아 어디서부터 볼지 모른다

증상: 목록이 수십~수백 개라 손을 못 댑니다.
원인: 거르는 원칙이 없는 것입니다.
해결: "메인 사이트 말고" 하나면 충분합니다 — dev/staging/old/test/api 접두어로 먼저 거르고, 낡은 기술 스택 헤더로 다시 좁히세요. [4]의 별표 로직이 그 자동화입니다.

벽 4. 스코프 밖 이름이 목록에 섞인다

증상: 수집 결과에 관계없는 도메인이 끼어 있습니다 (CDN, 제3자 서비스 등).
원인: 공개 출처 데이터는 잡음이 섞입니다.
해결: 프로브 전에 Step 314의 scope_check.py로 목록 전체를 걸러 스코프 밖을 삭제하세요. 수집 단계에서 섞이는 것은 흔하지만, 프로브가 나가는 순간 규칙 위반이 됩니다.

벽 5. 지도 없이 바로 공격부터 시작한다

증상: 호스트를 찾자마자 취약점 테스트를 시작합니다.
원인: 정찰을 "해야 하는 일"이 아니라 "건너뛰는 일"로 본 것입니다.
해결: 지도 문서를 먼저 완성하세요. 어디를 봤고 어디가 남았는지 모르면 같은 곳만 돌게 됩니다. 지도는 작업의 체크리스트이기도 합니다.


7. 정리

오늘의 개념

개념 한 줄 설명
정찰 체인 수집 → 생존 확인 → 분류 → 지도화, 전부 스코프 안에서
subfinder / amass 공개 출처 기반 서브도메인 수집 — 출처가 달라 함께 쓴다
httpx 라이브 호스트 추리기 + 상태·타이틀·기술 스택 출력
핑거프린팅 응답 헤더·페이지 내용으로 기술 스택 파악 (X-Powered-By 등)
JS 엔드포인트 수집 프런트엔드 코드에서 /api/... 경로 추출 — 숨겨진 지도
"메인 사이트 말고" dev/staging/old 우선 — 경쟁 없고 관리 안 되는 곳
404 ≠ 죽은 호스트 응답이 왔다는 것 자체가 생존의 증거

오늘의 명령어·코드

도구 하는 일
f"{word}.도메인" + 영역 조회 사전 대입 열거 (Step 171 복습)
requests.get(url) + 상태/타이틀/헤더 httpx 역할의 파이썬 재현
re.findall(r"['"](/api/[^'"]+)['"]", js) JS에서 API 경로 추출
re.match(r"(dev|old|test|staging|api).", name) 우선 후보 선별 규칙
(화면 예시) subfinder -d 도메인sort -uhttpx 실전 수집 파이프라인

명령어보다 중요한 감각

정찰의 산출물은 "목록"이 아니라 "지도"입니다 — 우선 후보에 별표가 붙고 스코프 검증 칸이 채워져야 지도입니다. 그리고 이 지도의 가치는 남들이 안 그린 구석에 있습니다. 방금 직접 만든 60줄짜리 스크립트가 subfinder+httpx 파이프라인의 뼈대 그대로라는 것 — 이제 실전 도구의 출력을 읽는 눈이 생겼습니다. 다음은 이 지도의 별표들을 하나씩 두드리는 일입니다.


전부 체크되면 Step 315 완료입니다.