23.06.15
DEVOTEE를 활성화 시키면
지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.
버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.
| 컨텐츠 유형 | 제목 | 저장일 | 삭제 |
|---|
본인인증 로그인에 실패하였습니다.
회원이 아니시거나 본인인증 등록이
완료되지 않은 사용자입니다.
보안관제센터가 공격 탐지 티켓을 올렸습니다.
5분 뒤 공격자 IP는 두 개의 방화벽에 등록됐고, 티켓에는 조치 완료 댓글이 달렸습니다. 사람은 자고 있었습니다.
04:53:56 차단 완료 : 130.49.215.90/32
05:39:04 차단 완료 : 122.10.17.49/32
개발자 커뮤니티 데보션(DEVOCEAN) 을 운영하다 보면 웹 공격 시도가 꾸준히 들어옵니다. SQL 인젝션, 검증되지 않은 리다이렉트, 파라미터 조작 같은 것들입니다.
보안관제센터가 이런 공격을 탐지하면 JIRA로 티켓이 올라옵니다. 담당자는 이렇게 대응합니다.
티켓에서 출발지(공격자) IP를 확인합니다.
AWS WAF의 IP set에 그 IP를 추가합니다.
티켓에 "차단했습니다" 댓글을 답니다.
단순 반복 작업입니다. 자동화하기로 했습니다.
그런데 이 작업의 난이도는 "IP를 넣는 것"에 있지 않습니다. "잘못 넣지 않는 것"에 있습니다. 자사 IP를 실수로 차단하면 그 순간 서비스 장애입니다.
이 글은 그 자동화를 만들면서 겪은 일의 기록입니다.
결론부터 말하면, 구현 시점에 세운 가정은 일곱 개가 틀렸고 보안 리뷰에서는 실제로 악용 가능한 결함이 나왔습니다.
그런데도 운영에서 사고는 한 건도 없었습니다. 그 이유가 이 글의 핵심입니다.
차단 자동화라고 하면 보통 "얼마나 빨리 막느냐"를 떠올립니다.
실제로는 반대입니다. 잘못 막았을 때의 비용이 압도적으로 크기 때문에, 설계의 대부분이 거부 조건에 들어갑니다.
IP 하나가 차단되기까지 통과해야 하는 관문을 여섯 개 두었습니다.
관문 | 거부 조건 |
|---|---|
allowlist | 자사 공인 대역·모니터링·파트너 IP |
예약 대역 | 사설(RFC1918), 루프백, 링크로컬, CGNAT |
중복 | 이미 등록된 IP |
CIDR 표기 |
|
티켓당 상한 | 한 티켓에서 N개 초과 |
실행당 상한 | 한 주기에서 N개 초과 |
여기에 더해 dry_run을 기본값 true로 두었습니다. 검증이 끝나기 전에는 WAF와 JIRA에 아무것도 쓰지 않습니다.
이 설계가 나중에 프로젝트를 구했습니다.
구현을 마친 뒤 보안 리뷰를 별도로 돌렸습니다. 실제로 악용 가능한 결함이 나왔습니다.
초기 파서는 "출발지 IP", "Source IP" 같은 라벨 뒤에 오는 IP를 신뢰했습니다. 라벨 목록은 이랬습니다.
_ATTACKER_LABELS = (
r"출발지\s*(?:IP|아이피)",
r"공격자\s*(?:IP|아이피)",
r"source\s*ip",
r"client\s*ip", # 문제
r"src[_\s-]?ip", # 문제
r"remote[_\s-]?addr", # 문제
)
# 라벨과 값 사이 구분자. '=' 와 개행을 모두 허용했습니다.
_SEPARATOR = r"[\s:=|>\-→]{0,10}"핵심은 관제 티켓 본문에 탐지된 HTTP 요청이 그대로 인용된다는 점입니다. 즉 본문의 일부는 공격자가 내용을 정할 수 있는 데이터입니다.
공격자가 이런 요청을 보내면,
GET /login?client_ip=203.0.113.10 HTTP/1.1그 문자열이 티켓 본문에 실립니다. 그리고 파서는 client_ip=203.0.113.10을 "신뢰할 수 있는 공격자 IP 표기"로 읽습니다.
리뷰어가 실제 모듈을 실행해 재현한 결과입니다.
input : 로그: "GET /a HTTP/1.1" 200 - "-" "curl/8.0 remote_addr=203.0.113.10"
result: source=labeled trusted=True WOULD BLOCK -> ('203.0.113.10/32')trusted=True이므로 "라벨 없는 IP는 차단하지 않는다"는 방어선을 그대로 우회합니다.
공격자가 203.0.113.10 자리에 자사 NAT 아웃바운드 IP나 CDN 오리진 IP를 넣으면, 우리 시스템이 우리 서비스를 차단합니다. 자기 자신을 향한 DoS입니다.
코딩 실수가 아닙니다. "티켓은 관제팀이 쓴 문서"라는 암묵적 가정이 문제였습니다.
실제로는 티켓 = 관제팀이 쓴 부분 + 공격자가 정한 부분이었습니다.
# 1. 로그·HTTP 헤더 필드명으로도 쓰이는 토큰을 라벨에서 제거
_ATTACKER_LABELS = (
r"출발지\s?(?:\([^)\n]{0,12}\))?\s?(?:IP|아이피)",
r"공격자\s?(?:\([^)\n]{0,12}\))?\s?(?:IP|아이피)",
r"source\s?ip",
)
# 2. 라벨은 줄 머리에서만 인정합니다.
# 관제 양식의 항목명은 줄 처음에 오지만, 주입 문자열은 줄 중간에 박힙니다.
_LINE_PREFIX = r"^[ \t>*_|]{0,4}"
# 3. 구분자에서 '='를 빼고 개행을 넘지 않습니다.
# '='는 URL 쿼리스트링과 구분되지 않고,
# 개행을 넘으면 표의 빈 셀일 때 다음 줄 값을 잘못 가져옵니다.
_SEPARATOR = r"[:|>\-→]?[ \t]*"
_LABELED_RE = re.compile(
_LINE_PREFIX + "(?:" + "|".join(_ATTACKER_LABELS) + ")" + _SEPARATOR + r"(?P<value>[^|\n]*)",
re.IGNORECASE | re.MULTILINE,
)여기에 하나를 더 추가했습니다. 원래 README에 "allowlist를 반드시 채우세요"라고 적어두었는데, 리뷰어가 지적했습니다.
문서에만 있는 안전장치는 안전장치가 아닙니다.
코드로 강제하도록 바꿨습니다.
if not agent.dry_run and not guard.allowlist:
raise ConfigError(
"dry_run이 false인데 allowlist가 비어 있습니다. "
"오차단 방지를 위해 자사 공인 대역을 먼저 등록하세요."
)실제로 배포 중에 이 검증에 한 번 걸렸습니다.
같은 시점에 돌린 코드 리뷰에서는 다른 종류의 결함이 나왔습니다.
# 문제: 화이트리스트에 없는 값을 전부 False로 처리했습니다.
def _as_bool(value, default):
return str(value).strip().lower() in ("1", "true", "yes", "on")WAFIP_DRY_RUN='ture' -> dry_run=False (실차단)
WAFIP_DRY_RUN='dry' -> dry_run=False (실차단)오타 하나가 안전장치를 통째로 끕니다. 인식할 수 없는 값은 기본값으로 넘기지 말고 예외를 던져야 합니다.
def _as_bool(value, default, where):
if value is None or value == "":
return default
if isinstance(value, bool):
return value
text = str(value).strip().lower()
if text in _TRUE_VALUES:
return True
if text in _FALSE_VALUES:
return False
raise ConfigError(f"'{where}' 값은 true/false여야 합니다: {value!r}")같은 패턴의 버그가 정수 설정에도 있었습니다.
int(value or 20)은 "아무것도 차단하지 마라"는 의도로 넣은 0을 기본값 20으로 되돌립니다.
or 폴백은 "명시적으로 비움"과 "설정하지 않음"을 구분하지 못합니다.
여기서부터가 진짜입니다. 실제 데이터를 보기 전에는 알 수 없던 것들입니다.
가정 | 실제 | 증상 |
|---|---|---|
프로젝트 키를 알고 있다 | 다른 프로젝트였음 | JQL 0건 |
제목에 "웹 취약점 공격" | 한글 '웹'이 아니라 영문 | JQL 0건 |
공격자 IP는 본문에 있다 | 별도 커스텀 필드에 있음 | 어떤 티켓에서도 IP를 못 찾음 |
WAF는 하나 | CloudFront와 리전, 스코프가 달라 IP set 공유 불가 | 한쪽 경로만 차단 |
| JIRA가 한글 로컬라이즈됨 | JQL 실패 가능 |
| 라벨이 없는 신규 이슈까지 제외됨 | 신규 이슈 누락 |
라벨을 붙일 수 있다 | 프로젝트 화면 구성에 labels 필드가 없음 | HTTP 400 |
에이전트가 어떤 티켓에서도 IP를 찾지 못했습니다. 로그는 이랬습니다.
{"level": "WARNING", "message": "[TICKET-1] 이슈에서 IP를 찾지 못했습니다."}파서 버그를 의심했지만 아니었습니다. 공격자 IP가 본문이 아니라 구조화된 커스텀 필드에 담겨 있었습니다.
Src_ip: 203.0.113.9 (RU / EXAMPLE-ORG)
Rawdata: Parameter: ID=167772&boardType=techBlog1) AND SLEEP(6)-- ...본문(Rawdata)에는 SQL 인젝션 페이로드만 있고 IP는 없었습니다.
필드 ID를 찾을 때는 이름을 추측하는 대신 IP 값이 실제로 들어 있는 필드를 직접 찾는 방법이 확실합니다.
curl -sS -H "Authorization: Bearer $JIRA_TOKEN" \
'https://your-jira/rest/api/2/issue/TICKET-1?expand=names' | python3 -c "
import json, re, sys
d = json.load(sys.stdin); names = d['names']; f = d['fields']
IP = re.compile(r'(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])')
for k, v in f.items():
if v is None: continue
if IP.search(str(v)): print(k, '|', names.get(k), '|', str(v)[:90])
"customfield_XXXXX | Src_ip | 203.0.113.9 (RU / EXAMPLE-ORG)이 발견은 뜻밖의 이득을 줬습니다. 구조화된 필드만 읽으면 되니, 본문 파싱을 아예 끌 수 있게 된 것입니다.
2장에서 다룬 인젝션 경로가 원천 차단됐습니다.
잠깐, 이건 관제 쪽 설계가 좋았던 덕입니다.
자동화가 성립한 결정적 조건은 관제 티켓이 정형화되어 있었다는 점입니다.
SK텔레콤 보안관제센터(SMC)가 올리는 티켓에는 탐지 시나리오, 로그 소스, 탐지 건수, 대상 도메인, 그리고 출발지 IP가 각각 별도 필드로 담겨 옵니다.
원본 페이로드도 따로 분리돼 있습니다.
만약 이 정보가 자유 서술형 본문에만 있었다면, 파싱에 의존할 수밖에 없고 그건 2장의 인젝션 위험을 안고 가는 길입니다.
정형 필드 하나가 위협 모델 전체를 바꿉니다.
jira:
ip_field: customfield_XXXXX
guard:
require_ip_field: true # 본문 파싱 결과를 신뢰하지 않습니다세 가지를 기록해 둘 만합니다.
첫째, 다중값 필드의 !=는 EMPTY를 제외합니다.
-- 문제: 라벨이 하나도 없는 신규 이슈가 조회되지 않습니다.
labels != processed
-- 수정
(labels IS EMPTY OR labels NOT IN (processed, needs-review))둘째, 상태 이름은 로컬라이즈됩니다.
-- 한글 JIRA에서는 동작하지 않을 수 있습니다.
status not in (Closed, Done)
-- 언어와 무관하게 동작합니다.
statusCategory != Done셋째, 검색어는 실제 문자열을 그대로 복사해야 합니다.
한글 '웹'으로 검색해서 0건이 나왔는데, 실제 표기는 영문 Web이었습니다.
AWS WAF는 스코프가 두 가지입니다.
스코프 | 리전 | ARN 경로 |
|---|---|---|
| 항상 |
|
| 리소스가 있는 리전 |
|
CDN 앞단과 ALB 앞단에 각각 WAF가 있다면 IP set이 별개입니다. 한쪽만 등록하면 다른 경로가 열려 있습니다.
여기서 중요한 설계 판단이 하나 나옵니다. "이미 등록됨" 판정을 모든 IP set의 교집합으로 해야 합니다.
@staticmethod
def _registered_everywhere(existing: dict[str, set[str]]) -> frozenset[str]:
"""모든 IP set에 이미 들어 있는 항목만 '등록됨'으로 본다.
합집합으로 판정하면 CloudFront에만 있고 ALB에는 없는 IP를
'이미 등록됨'으로 처리해, 그쪽 차단이 영구 누락된다.
"""
sets = list(existing.values())
return frozenset(set.intersection(*sets)) if sets else frozenset()실제로 두 IP set의 내용은 달랐습니다(161건과 263건). 교집합이 아니었다면 누락이 발생했을 상황입니다.
주목할 점은 모든 실패가 "0건" 또는 "수동 확인 요청"으로 나타났다는 것입니다. 잘못된 IP를 차단한 경우는 한 번도 없었습니다.
안전 실패(fail-safe) 설계를 기본값으로 둔 덕분입니다.
dry_run 기본값 true, 인식 불가 값은 예외
라벨을 못 찾으면 차단하지 않고 댓글로 사람에게 넘김
allowlist가 비면 실차단 모드 전환 자체를 거부
검증 기간에는 IAM에 읽기 권한만 부여 (dry_run에서는 UpdateIPSet을 호출하지 않으므로)
이게 왜 중요하냐면, 실제 데이터로만 검증되는 종류의 가정들이 있기 때문입니다.
티켓의 실제 형식, 필드 위치, 표기 언어 같은 것들은 스테이징에서 완벽히 검증할 방법이 없습니다.
안전 실패가 있으면 운영 환경에서 반복 검증할 수 있습니다. 없으면 완벽히 검증한 뒤에만 켤 수 있는데, 그건 대개 불가능합니다.
배포 시간을 크게 줄인 것은 CLI 서브커맨드였습니다.
wafip show # 설정 출력 + 대상 IP set 조회
wafip check-ip X # 특정 IP의 판정 결과만 확인 (변경 없음)
wafip once # 1회만 폴링특히 show의 오류 메시지에 호출 신원이 그대로 찍혀서 IAM 디버깅에 별도 도구가 필요 없었습니다.
AccessDeniedException: User: arn:aws:sts::...:assumed-role/agent-role/i-xxxx
is not authorized to perform: wafv2:GetIPSet on resource: ...역할이 제대로 붙었는지, 정책이 빠졌는지가 한 줄로 판별됩니다.
배포 담당자가 각 단계를 독립적으로 확인할 수 있어야 합니다. 마지막에 한꺼번에 확인하면 원인 분리가 안 됩니다.
운영 첫날 새벽에 두 건이 정상 차단됐습니다. 그런데 라벨 부여에서 HTTP 400이 났습니다.
Field 'labels' cannot be set. It is not on the appropriate screen, or unknown.수정하면서 커밋 메시지에 이슈 키를 적었습니다. "첫 두 건(TICKET-1, TICKET-2)은 정상 차단됐으나..." 같은 식으로요.
푸시하고 몇 분 뒤, 그 커밋 메시지 전문이 해당 관제 티켓의 댓글로 올라갔습니다. 토요일 밤, 참조자 15명에게 메일이 나갔습니다.
저장소에 GitLab-JIRA 연동이 걸려 있었습니다. 커밋 메시지에서 이슈 키를 인식하면 커밋 내용을 그 티켓에 자동으로 붙이는 기능입니다.
개발 프로젝트에서는 유용하지만, 여기서는 티켓이 관제 대응 기록이었고 수신자가 보안 조직 전체였습니다.
방지책으로 커밋 훅을 만들었습니다.
#!/bin/sh
# .githooks/commit-msg
keys=$(grep -v '^#' "$1" | grep -oE '\b(PROJ|SEC)-[0-9]+\b' | sort -u)
if [ -n "$keys" ]; then
echo "커밋 거부: 커밋 메시지에 JIRA 이슈 키가 있습니다." >&2
echo "$keys" | sed 's/^/ - /' >&2
exit 1
fi그런데 이것도 부족했습니다. 연동은 커밋 메시지뿐 아니라 브랜치명, MR 제목·본문, MR 댓글에도 반응합니다. 훅은 그중 하나만 막습니다.
근본 차단은 GitLab 프로젝트 설정에서 연동의 댓글 기능을 끄는 것이었습니다.
Settings → Integrations → Jira → "Enable comments" 해제
방지책을 만들 때도 "이 통제가 경로 전부를 덮는가"를 따져야 한다는 걸, 방지책을 만들면서 놓쳤습니다.
이 프로젝트의 핵심 결함은 코딩 실수가 아니라 신뢰 경계 설정 오류였습니다. 외부 데이터를 소비하는 자동화라면, 입력마다 **"이 문자열을 누가 정할 수 있는가"**를 명시적으로 따져야 합니다.
보안 리뷰와 코드 리뷰를 각각 별도로 돌렸고, 둘 다 스스로는 찾지 못한 결함을 냈습니다. 같은 맥락 안에서 자기 코드를 검토하면 같은 가정을 반복합니다.
가정이 일곱 개 틀렸는데도 사고가 없었던 유일한 이유입니다. 모든 실패 경로가 "아무것도 하지 않고 사람에게 넘김"으로 수렴하도록 설계했습니다.
"allowlist를 먼저 채우세요"는 지켜지지 않을 수 있습니다. 코드가 거부하도록 바꾼 뒤에야 실효가 생겼습니다.
이 프로젝트에서 두 번 데였습니다. 에이전트 댓글 하나가 15명에게 메일로 나간다는 것, 그리고 커밋 메시지가 티켓에 자동 게시된다는 것.
후자는 내가 만든 자동화가 아니라 이미 거기 있던 자동화였고, 그래서 시야에 없었습니다.
티켓 시스템, 채팅 연동, 메일 알림, CI 트리거. 출력이 닿는 모든 곳이 증폭기가 될 수 있습니다.
설계부터 운영까지 하루가 걸렸고, 테스트 98건에 커밋 7개가 남았습니다.
가장 인상적이었던 순간은 운영 첫날 새벽에 온 알림 메일이었습니다.
04시 53분에 관제 통보가 올라왔고, 수 분 안에 공격자 IP가 두 방화벽에 등록되고 티켓에 회신까지 나갔습니다.
아침에 출근해서 봤다면 네 시간쯤 열려 있었을 경로입니다.
다만 자동화의 값어치는 속도가 아니라 신뢰도에서 나옵니다. 잘못 차단하는 자동화는 없느니만 못합니다.
그래서 이 프로젝트에서 가장 시간을 많이 쓴 부분도 "어떻게 빨리 막을까"가 아니라 "어떻게 잘못 막지 않을까"였습니다.
혹시 비슷한 자동화를 만드신다면, 첫 줄을 쓰기 전에 이 질문부터 던져 보시길 권합니다.
이 입력의 어느 부분을 공격자가 정할 수 있는가?
이번 보안 차단 에이전트는 데보션을 운영하면서 시도해 본 자동화 중 하나입니다. 그 전에도 몇 가지가 있었습니다.
시도 | 무엇을 자동화했나 |
|---|---|
데보션 알리미 | DEVOCEAN에 등록되는 블로그, 뉴스, AI블로그를 아침10시에 이메일로 발송해주는 에이전트 |
RAG 자동화 에이전트 | DEVOCEAN에 등록된 모든 컨텐츠(블로그, 커뮤니티)를 임베딩하고, 매일 한번씩 변경 사항을 파악해서 추천 블로그와 검색 시 사용할 수 있게 하는 Agentic RAG 에이전트 |
DEVOTEE | 매일 아침 IT 소식을 모아 한국어 글로 쓰고 발행하는 에이전트 (AI 블로그 작성) |
보안 차단 에이전트 | 관제 통보에 사람 없이도 즉시 대응하도록 (지금 이 블로그^^) |
특히 DEVOTEE는 수집부터 발행까지를 하나의 파이프라인으로 묶은 사례입니다.
GitHub Actions가 매일 아침 정해진 시각에 파이프라인을 돌립니다. 여러 기술 소식 소스에서 글감을 모으고, 오늘 쓸 주제를 고르고, 한국어 본문을 작성하고, 품질을 검증한 뒤 데보션에 올립니다.
네 가지 모두 성격은 다르지만 출발점은 같습니다.
사람이 반복해서 하던 일 중에, 기계가 더 잘할 수 있는 부분을 골라내는 것입니다.
특히 이번 건은 "빠르게 하는 자동화"가 아니라 "틀리지 않는 자동화"를 만드는 연습이었습니다.
관문 여섯 개, 안전 실패 설계, 별도 리뷰 두 번 — 전부 속도를 포기하고 신뢰도를 산 선택입니다.
데보션이라는 서비스를 직접 운영하고 있다는 점이 이런 시도를 가능하게 합니다.
실제 트래픽이 있고, 실제 공격이 들어오고, 실패하면 실제로 아픕니다. 그 조건이 아니었다면 이 글의 3장(가정 일곱 개가 깨진 과정)은 나오지 않았을 겁니다.
앞으로도 데보션에서 해 볼 수 있는 일이 많이 남아 있습니다.
요즘 에이전트를 만드는 일이 흔해졌습니다. 다만 대부분은 보여주는 것에서 멈춥니다.
1단계부터 3단계까지는 마지막에 항상 사람이 있습니다.
에이전트가 아무리 잘 정리해 줘도, 사람이 읽고 판단하고 콘솔에 들어가 버튼을 눌러야 일이 끝납니다.
성과를 재기 어려운 것도 그래서입니다. 조회성 에이전트의 지표는 대개 활동량입니다.
조회 수, 알림 발송 건수, 생성한 문서 수. 에이전트가 얼마나 바빴는지는 말해 주지만, 무엇이 달라졌는지는 말해 주지 않습니다.
이번 에이전트의 지표는 성격이 다릅니다.
지표 | 값 |
|---|---|
통보에서 차단 완료까지 | 수 분 |
야간·주말 대응을 위한 사람 개입 | 없음 |
오차단 | 0건 |
일을 끝까지 했기 때문에 성과가 그 자체로 지표가 됩니다.
4단계로 올라가려면 넘어야 할 선이 하나 있습니다.
내 권한을 에이전트에게 주는 것입니다.
이 에이전트는 제 계정 토큰으로 움직이고, 제 이름으로 티켓에 댓글을 답니다.
운영 WAF의 차단 목록을 직접 바꿉니다. 제가 자고 있는 동안에요.
그래서 이 글의 대부분이 안전장치 이야기였습니다.
관문 여섯 개, 기본값 dry run, allowlist 강제, 별도 리뷰 두 번, 그리고 실패하면 아무것도 하지 않고 사람에게 넘기는 설계.
권한을 받은 에이전트는 그만큼의 설계 부담을 집니다.
그 대가를 치를 생각이 없다면 3단계에 머무르는 편이 낫습니다.
만들기 쉬운 에이전트와 정말 필요한 에이전트는 대체로 다릅니다.
요약봇과 검색봇이 많이 만들어지는 데는 이유가 있습니다.
만들기 쉽고, 틀려도 아무 일이 일어나지 않기 때문입니다.
반대로 조직에 정말 필요한 자동화는 대개 위험한 쪽에 있습니다.
실제 시스템을 바꾸고, 틀리면 장애가 나고, 그래서 아무도 손대지 않은 채 사람이 계속 반복하고 있습니다.
이번 건도 그랬습니다. 작업 자체는 어렵지 않은데 아무도 자동화하지 않고 있었던 이유는, 잘못 막으면 서비스가 멈추기 때문이었습니다.
할 수 있는 것을 하는 에이전트가 아니라, 해야 하는 것을 하는 에이전트를 만들었으면 합니다.
그러려면 권한을 넘기는 결정과, 그 권한을 안전하게 다루는 설계가 함께 가야 합니다.
이 글이 그 설계에 참고가 되면 좋겠습니다.
DEVOTEE를 활성화 시키면
지금 작성한 댓글에 AI가 댓글을 달아줍니다.