
고객센터로 "청구서 메일을 받았는데 진짜 귀사가 보낸 것이냐"는 문의가 옵니다. 보낸 사람 주소는 우리 도메인입니다. 우리는 그런 메일을 보낸 적이 없습니다. DMARC 레코드는 1년 전에 p=quarantine으로 걸어 두었고, 보고서 받는 주소(rua)도 적어 두었지만 그 우편함은 아무도 열어 보지 않았습니다. 열어 보니 XML 첨부가 수백 개 쌓여 있습니다. 이번 글은 그 XML에서 "우리 이름으로 메일을 보낸 IP 목록"을 꺼내, 모르는 발송처를 찾는 일을 직접 해 봅니다.

왼쪽에서 오른쪽으로 흐릅니다. 맨 왼쪽은 남이 보내 주는 것이고, 가운데부터는 내가 만드는 것입니다. 아래 노란 띠는 이 흐름이 DMARC 보고서 분석 서비스가 하는 일의 뼈대라는 뜻입니다.
DMARC(Domain-based Message Authentication, Reporting, and Conformance)는 세 가지를 합니다. 내 도메인으로 온 메일을 어떻게 확인할지(SPF·DKIM 정렬), 실패하면 어떻게 할지(p=), 그리고 결과를 알려 달라(rua=). 이 글은 세 번째입니다.
rua를 적어 두면 메일을 받는 쪽(Mail Receiver)이 정해진 기간(보통 UTC 하루)의 결과를 모아 XML로 보내 줍니다. 그 안에는 메일 본문이 없고, 보낸 IP와 개수와 인증 결과만 있습니다. 10편에서 본 이메일 IP 평판이 "남이 내 IP를 어떻게 보나"였다면, 집계 보고서는 "누가 내 이름을 쓰고 있나"를 받는 쪽이 대신 세어 준 것입니다.
DMARCeye 용어집은 자사 서비스가 DMARC 집계 보고서의 발송 IP를 전 세계 평판 데이터·차단 목록과 엮어 의심스럽거나 등재된 IP를 표시한다고 설명합니다. 이 글의 실습은 그 뼈대를 로컬에서 작게 재현한 것입니다.

왼쪽이 옛 문서, 오른쪽 셋이 새 문서입니다. 레코드의 버전 표시는 여전히 v=DMARC1입니다.
기획서는 "RFC 7489 기준"으로 적었지만, 원문을 확인해 보니 2026년 5월에 DMARC가 새 문서로 바뀌었습니다(RFC Editor 확인).
| 문서 | 제목 | 상태 |
|---|---|---|
| RFC 9989 | DMARC 본체 | Proposed Standard, 2026-05. RFC 7489와 RFC 9091을 대체 |
| RFC 9990 | DMARC Aggregate Reporting(집계 보고서) | Proposed Standard, 2026-05. RFC 7489를 대체 |
| RFC 9991 | DMARC Failure Reporting(실패 보고서) | Standards Track, 2026-05. RFC 7489를 대체, RFC 6591 갱신(이 시리즈에서는 다루지 않음) |
RFC 7489(2015)는 "정보성(Informational)" 문서였고, 새 문서들은 표준 트랙입니다. 보고서를 읽는 사람에게 중요한 변화는 이렇습니다.
np(없는 하위 도메인 정책), psd, t(시험 모드)가 생기고, pct·rf·ri(보고 간격)가 빠졌습니다. 10편 실습이 pct를 경고한 이유입니다.urn:ietf:params:xml:ns:dmarc-2.0이 생겼고, DKIM 결과에 selector가 필수가 됐고, extension 자리가 생겼습니다.현실의 우편함에는 한동안 두 형식이 섞여 들어올 것입니다. 이 글의 파서는 둘 다 읽게 만들었습니다.

들여쓰기가 XML의 포함 관계입니다. 노란 칠은 RFC 7489에 없고 RFC 9990에서 생긴 것입니다. 아래 두 상자는 자주 헷갈리는 값입니다.
보고서 하나는 정책 도메인 하나, 기간 하나입니다. 그 안의 record 한 줄은 "이 IP에서, 이런 결과 조합으로, 몇 통"입니다. 같은 IP라도 결과가 다르면 줄이 나뉩니다. 실습의 수신자 B 보고서(RFC 9990 형식, gzip)를 풀어 보면 이렇습니다.
실제 출력(2026-10-31, gzip -dc 앞 20줄 중 일부):
<feedback xmlns="urn:ietf:params:xml:ns:dmarc-2.0">
<version>1.0</version>
<report_metadata>
<org_name>Receiver B</org_name>
<email>dmarc-reports@receiver-b.example</email>
<report_id>b-7f3a9c2e</report_id>
<date_range><begin>1793232000</begin><end>1793318399</end></date_range>
</report_metadata>
<policy_published>
<domain>good.mail.lab.example</domain><adkim>r</adkim><aspf>r</aspf><p>quarantine</p><sp>quarantine</sp><np>none</np><testing>n</testing><discovery_method>treewalk</discovery_method>
</policy_published>
<record>
<row>
<source_ip>192.0.2.10</source_ip>
<count>640</count>
<policy_evaluated><disposition>pass</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
date_range는 유닉스 시각(UTC)입니다. 1793232000은 2026-10-29 0시 UTC입니다. 첨부 파일 이름도 규칙이 있습니다. receiver-b.example!good.mail.lab.example!1793232000!1793318399.xml.gz처럼 수신자!정책 도메인!시작!끝입니다(RFC 9990 3.5.2절).
disposition은 받는 쪽이 실제로 한 처리입니다. RFC 9990은 pass(강제 정책 아래에서 통과)를 새로 두었고, RFC 7489 보고서는 통과한 메일도 none으로 적습니다. 그래서 통과 여부는 disposition이 아니라 policy_evaluated의 dkim·spf로 셉니다.

왼쪽이 받는 사람이 보는 보낸 사람, 가운데가 날것의 인증 결과, 오른쪽이 수신자가 정렬까지 따진 결과입니다. 아래 띠는 실습 데이터의 실제 경우입니다.
보고서에는 인증 결과가 두 군데 있습니다.
auth_results: SPF·DKIM이 각자 확인한 날것의 결과. SPF는 봉투 발신자(envelope_from, 반송 주소) 도메인, DKIM은 서명한 도메인(d=)을 봅니다.policy_evaluated: 그 결과가 From 도메인과 정렬됐는지까지 본 결과. DMARC 판정은 이쪽입니다.뉴스레터 업체(ESP)를 쓰면 흔히 그림 같은 모양이 나옵니다. 반송 주소가 업체 도메인이라 SPF는 통과해도 정렬이 안 됩니다. 업체가 우리 도메인 키로 DKIM 서명을 해 주면 DKIM이 정렬되고, 둘 중 하나가 통과했으니 DMARC 통과입니다. 조직 도메인 판단(옛 방식은 공개 접미사 목록, RFC 9989는 DNS 트리 워크)은 수신자가 합니다. 그래서 판정은 policy_evaluated를 믿고, auth_results는 실패 원인을 찾을 때 씁니다.
lab/email/dmarc/에 재료가 있습니다. 모든 IP는 문서용, 도메인은 *.example입니다.
make_fake_reports.py: 2026-10-28~30의 가짜 보고서 네 개. 수신자 A·C는 RFC 7489 형식(이름공간 없음), B는 RFC 9990 형식에 gzipknown_senders.json: 발송처 대장. 우리 도메인으로 보내도 되는 곳의 목록입니다. 사내 메일 서버 두 대, 옛 알림 서버, 뉴스레터 업체의 대역parse_dmarc.py: 보고서를 모두 읽어 IP별로 합치고, 대장과 맞대고, 경보를 내고, JSON을 씁니다파서는 받은 XML을 남이 만든 입력으로 다룹니다. 압축을 풀고, 크기를 제한하고, 이름공간을 떼어 두 형식을 같은 방식으로 읽습니다. 실습은 표준 라이브러리 파서를 썼지만, 운영에서는 외부 엔터티와 압축 폭탄을 막는 파서(defusedxml 등)와 압축 해제 크기 제한을 둡니다. RFC 9990도 8.1절에서 보고서 자체가 공격 수단이 될 수 있다고 적습니다.
cd ip-reputation/lab/email
../.venv/bin/python fake_dns.py --port 18630 & # 10편의 로컬 DNSBL(교차 확인용)
cd dmarc
../../.venv/bin/python make_fake_reports.py
../../.venv/bin/python parse_dmarc.py --dnsbl-zone bl.lab.example
kill %1 # 로컬 DNS 서버 끄기
실제 출력(2026-10-31, macOS, Python 3.12.12):
보고서 4개
Receiver A Mail RFC 7489 2026-10-28 rows=4 p=quarantine id=a-20261028-001
Receiver A Mail RFC 7489 2026-10-29 rows=4 p=quarantine id=a-20261029-001
Receiver B RFC 9990 2026-10-29 rows=4 p=quarantine id=b-7f3a9c2e
Receiver C RFC 7489 2026-10-30 rows=4 p=quarantine id=c-2026-10-30-17
source_ip known_as msgs pass fail rate dkim_al spf_al status
203.0.113.66 - 2762 0 2762 0% 0 0 unknown_fail
203.0.113.67 - 88 0 88 0% 0 0 unknown_fail
198.51.100.200 - 35 0 35 0% 0 0 unknown_fail_forwarded
198.51.100.77 - 210 210 0 100% 210 0 unknown_pass
192.0.2.10 own-mta-1 3038 3035 3 100% 3035 3035 authorized_with_failures
192.0.2.11 own-mta-2 779 779 0 100% 779 779 authorized_pass
192.0.2.12 legacy-app 60 60 0 100% 0 60 authorized_pass
198.51.100.5 newsletter-esp 9710 9710 0 100% 9710 0 authorized_pass
경보
[HIGH ] 203.0.113.66 unknown_fail 2762통, 수신자 3곳, 2026-10-29~2026-10-30 reasons=['unknown_sender', 'dmarc_fail_rate=100%', 'seen_by_3_receivers', 'unknown_neighbors_in_/24=1', 'dnsbl_listed:bl.lab.example']
[HIGH ] 203.0.113.67 unknown_fail 88통, 수신자 1곳, 2026-10-30~2026-10-30 reasons=['unknown_sender', 'dmarc_fail_rate=100%', 'unknown_neighbors_in_/24=1']
[MEDIUM] 198.51.100.77 unknown_pass 210통, 수신자 1곳, 2026-10-29~2026-10-29 reasons=['unknown_sender', 'passes_dmarc_check_who_holds_key', 'unknown_neighbors_in_/24=1']
[MEDIUM] 192.0.2.10 authorized_with_failures 3038통, 수신자 2곳, 2026-10-28~2026-10-29 reasons=['dmarc_fail=3', 'seen_by_2_receivers']
wrote out/dmarc-senders.json

위 출력을 표로 옮긴 그림입니다. 위 네 줄(레드·노랑)이 경보 대상, 아래 그린 줄은 대장에 있고 통과한 발송처입니다.
표를 읽어 봅니다.
p=quarantine 덕에 받는 쪽이 격리했습니다(dispositions: quarantine 2762).mailing_list로 표시했습니다. 누군가 우리 메일을 메일링 리스트로 다시 보낸 것으로 보입니다. 경보 단계가 낮습니다.crm1로 우리 도메인 DKIM 서명이 붙었습니다. 마케팅팀이 새 CRM 서비스를 붙였을 수 있습니다. 정상일 수도 있지만, 대장에 없는 곳이 우리 키를 쓰고 있다는 사실은 반드시 확인합니다.
위 검은 띠 두 개가 첫 질문(대장에 있나)과 두 번째 질문(통과율은)입니다. 그 아래 다섯 상자가 결과 칸입니다. 90%와 대장은 글쓴이가 정한 실습 값입니다.
규칙의 핵심은 두 가지입니다.
unknown_pass)는 사칭보다 더 조용한 사고일 수 있습니다. 예를 들어 퇴사한 담당자가 만든 SaaS 계정이 아직 우리 키로 서명하고 있는 경우입니다.reasons에는 점수 대신 근거를 문자열로 남깁니다. 수신자 몇 곳이 봤는지, 같은 /24에 모르는 IP가 더 있는지, 로컬 DNSBL에 올라 있는지. 이 근거 목록을 13편이 다른 평판 소스와 합칩니다.
 → 13편 어댑터가 내 도메인 기준 신호로 읽어 Talos·Criminal IP·DNSBL과 같은 IP로 합침(제안) → 근거 묶음(unknown_fail, dnsbl_listed, seen_by_3_receivers). 할 일은 IP 차단이 아니라 p=reject 준비, 사칭 신고, 모르는 SaaS 정리](./images/11-hub.webp)
왼쪽 흰 상자가 이 편의 실제 결과, 가운데와 오른쪽 노란 점선 상자가 13편과의 연동 방식에 대한 글쓴이의 제안입니다.
파서가 쓴 JSON은 IP로 바로 찾을 수 있게 senders를 IP 키의 사전으로 둡니다. 13편 평판 모으기 서비스가 이 파일을 읽습니다.
실제 출력(2026-10-31, out/dmarc-senders.json의 203.0.113.66 항목):
"203.0.113.66": {
"status": "unknown_fail",
"severity": "high",
"known_as": null,
"messages": 2762,
"dmarc_pass": 0,
"dmarc_fail": 2762,
"pass_rate": 0.0,
"first_seen": "2026-10-29",
"last_seen": "2026-10-30",
"receivers": ["Receiver A Mail", "Receiver B", "Receiver C"],
"dispositions": {"quarantine": 2762},
"unknown_neighbors_24": ["203.0.113.67"],
"reasons": ["unknown_sender", "dmarc_fail_rate=100%", "seen_by_3_receivers", "unknown_neighbors_in_/24=1", "dnsbl_listed:bl.lab.example"],
"dnsbl": {"zone": "bl.lab.example", "zone_healthy": true, "verdict": "LISTED", "codes": ["127.0.0.4", "127.0.0.2"]}
}
(가독성을 위해 줄을 합치고 dkim_aligned 등 몇 칸을 뺐습니다. 원문은 lab/results/11-dmarc-parse.txt)
여기서 오해하기 쉬운 점이 있습니다. 이 IP를 내 방화벽에서 막아도 소용없습니다. 사칭 메일은 내 서버를 지나지 않고 공격자 IP에서 고객의 메일함으로 바로 갑니다. 할 일은 다른 곳에 있습니다.
p=quarantine을 p=reject로 올릴 준비를 합니다.unknown_pass(198.51.100.77)의 주인을 찾아 대장에 넣거나 키를 회수합니다.평판 모으기 서비스에서 이 결과가 쓰이는 곳은 "이 IP가 우리 고객 로그인에도 나타나는가" 같은 교차 확인입니다. 사칭 메일을 보낸 대역이 로그인 시도에도 보이면, 피싱으로 얻은 계정을 시험하는 중일 수 있습니다.

위 시간선은 하루 단위 보고가 얼마나 늦는지를 보여 주는 예입니다(시각은 설명용). 아래 세 상자가 이 편의 한계입니다.
rua를 지원하는 수신자만 보고서를 보냅니다. 보고서를 안 보내는 메일함으로 간 사칭은 이 표에 없습니다.reason의 mailing_list·trusted_forwarder를 따로 다룹니다.disposition 표기가 수신자와 형식(RFC 7489/9990)마다 다릅니다. 같은 날 같은 IP가 수신자마다 다른 줄로 나옵니다.rua를 맡길 때는 처리 위탁 여부를 확인합니다(법률 자문 아님).rua 우편함을 파서에 연결하고, 두 형식(RFC 7489·9990)과 gzip·zip 첨부를 모두 받게 한다. 파서는 크기 제한과 안전한 XML 파서를 쓴다.p=reject로 올린다. 사칭 IP는 막는 대상이 아니라 정책과 신고의 근거다.pct·ri 같은 태그가 빠졌다. 한동안 두 형식이 섞여 온다.policy_evaluated로 세고, 발송처 대장과 맞대 모르는 발송처를 가린다. 대장에 없는 통과도 경보다.모든 링크는 2026-10-31에 확인했습니다.
이전 글 이메일 발송 IP 평판: 무엇을 보고 점수를 매기나
다음 글 도입 체크리스트: 고를 때 물어야 할 여덟 가지
시리즈 전체 목록 IP 평판은 어떻게 만들어지고, 어떻게 써야 하나 전체 목록