예전 금융권 관련 회사에서 인프라/보안 팀장을 맡았을 때, 관제사에서 주기적으로 주는 차단 IP 목록을 AWS WAF에 자동 반영해야 할 필요가 있었다. 그때 구성했던 방식을 약간 가공하여 정리해 둔다.
목표
- 관제에서 전달되는 **차단 IP 목록(.txt, 줄단위)**을 자동 집계하여 WAF IP set에 반영.
- S3 업로드 시 즉시 반영, **매일 정시 전체 집계(90일 이내 파일만)**로 재반영.
- CloudFront에 붙는 WAFv2(Region us-east-1, Scope=CLOUDFRONT) 대상으로 업데이트.
왜 “두 가지” Lambda가 필요한가?
관제에서 주는 원본이 환경마다 다르다.
- 케이스 A: 이미 CIDR 형식으로 제공 (예:
203.0.113.0/24
,198.51.100.10/32
)
→ 그대로 WAF IP setAddresses
에 넣으면 된다. - 케이스 B: 비-CIDR 단일 IP로 제공 (예:
203.0.113.5
,198.51.100.10
)
→ WAF는 CIDR만 받으므로x.x.x.x/32
로 정규화해서 반영해야 한다.
현장에서 관제 포맷이 바뀌거나 공급자가 여러 곳이면, 두 케이스를 각각 지원하면 운영이 편하다. (같은 Lambda 안에서 자동 판별도 가능하지만, 여기선 명시적으로 두 버전을 준비해 안전하게 구분 운영한다.)
전체 흐름
- 관리 계정 웹서버:
.txt
업로드 → - 운영 계정 S3 (ipblocklists) 저장 →
- S3 ObjectCreated 트리거 → 운영 계정 Lambda → WAF IP set 업데이트
- EventBridge(매일) → Lambda → ip.php 전체 집계(90일 이내) → WAF IP set 재반영

- Lambda가
ip.php
에 접근해야 하므로 VPC + NAT(Outbound 443) 필요 - 웹 EC2 SG는 Inbound 443 허용(출발지: NAT GW)
권한 / 정책 – 환경에 맞게 응용 필요
관리 계정 EC2 Role : ipblock-ec2
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:*", "s3-object-lambda:*"],
"Resource": [
"arn:aws:s3:::ipblocklists",
"arn:aws:s3:::ipblocklists/*"
]
}]
}
운영 계정 S3 버킷 정책 (관리 계정 EC2 접근 허용)
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::88853452443:role/ipblock-ec2" },
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::ipblocklists",
"arn:aws:s3:::ipblocklists/*"
]
}]
}
운영 계정 Lambda Role (ipblock role)
- AwsWafFullAccess
- CreateNetworkInterface-Lambda (VPC+NAT 경유 시 필요)
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"ec2:DescribeNetworkInterfaces",
"ec2:CreateNetworkInterface",
"ec2:DeleteNetworkInterface",
"ec2:DescribeInstances",
"ec2:AttachNetworkInterface"
],
"Resource": "*"
}]
}
참고: 이 구성에서는 Lambda가 S3를 직접 읽지 않으므로 AmazonS3ReadOnlyAccess 불필요.
트리거 & 네트워크
- S3:
ObjectCreated:*
→ Lambda - EventBridge(매일):
cron(12 00 ? * * *)
(UTC 00:12) → Lambda - Lambda VPC SG: Inbound deny, Outbound TCP 443 allow
- 웹서버 EC2 SG: Inbound 443 allow (Source: NAT Gateway)
Lambda python code (케이스 A: 관제 원본이 이미 CIDR 형식일 때)
ip.php가
x.x.x.x/nn
라인만 내보내는 케이스. 그대로 WAF IP set에 넣는다. HTML<br />
가 섞일 수 있어 제거 로직 포함.
import logging
import boto3
import urllib.request
from ipaddress import ip_network
# 고정값 (CloudFront WAFv2)
WAF_REGION = 'us-east-1'
WAF_SCOPE = 'CLOUDFRONT'
# 수동 지정
IPV4_SET_NAME = "test" # WAF IP set 이름
IPV4_SET_ID = "359fec83-b340-4c16-9bba-7b91f92eb7ee" # WAF IP set ID
INFO_LOGGING = 'false'
def fetch_ip_list_from_url(url: str) -> list[str]:
resp = urllib.request.urlopen(url, timeout=10)
lines = resp.read().decode('utf-8', errors='ignore').splitlines()
# 혹시 섞인 <br /> 제거
return [line.replace('<br />', '').strip() for line in lines if line.strip()]
def lambda_handler(event, context):
try:
if len(logging.getLogger().handlers) > 0:
logging.getLogger().setLevel(logging.ERROR)
else:
logging.basicConfig(level=logging.ERROR)
if INFO_LOGGING == 'true':
logging.getLogger().setLevel(logging.INFO)
# CIDR 라인만 출력하는 ip.php
ip_list = fetch_ip_list_from_url('https://example.com/ip.php')
# 유효성(형식) 검증 & 정규화 (예: 1.2.3.4/32 → 1.2.3.4/32)
cidrs: list[str] = []
for raw in ip_list:
try:
cidrs.append(str(ip_network(raw, strict=False)))
except Exception:
logging.info(f"Skip invalid line (not CIDR): {raw}")
update_waf_ipset(IPV4_SET_NAME, IPV4_SET_ID, cidrs)
return {'status': f'IP 세트 업데이트 완료: {len(cidrs)} entries'}
except Exception as e:
logging.error('에러 발생', exc_info=True)
return {'status': f'에러 발생: {e}'}
def update_waf_ipset(ipset_name: str, ipset_id: str, addresses: list[str]) -> None:
waf = boto3.client('wafv2', region_name=WAF_REGION)
lock_token = get_lock_token(waf, ipset_name, ipset_id)
waf.update_ip_set(
Name=ipset_name,
Scope=WAF_SCOPE, # CLOUDFRONT
Id=ipset_id,
Addresses=addresses,
LockToken=lock_token
)
def get_lock_token(waf, ipset_name: str, ipset_id: str) -> str:
res = waf.get_ip_set(Name=ipset_name, Scope=WAF_SCOPE, Id=ipset_id)
return res['LockToken']
Lambda python code (케이스 B: 관제 원본이 비-CIDR 단일 IP일 때 → /32
로 변환)
ip_network(x, strict=False)
를 쓰면1.2.3.4
→1.2.3.4/32
로 자동 변환된다.
import logging
import boto3
import urllib.request
from ipaddress import ip_network
# 고정값 (CloudFront WAFv2)
WAF_REGION = 'us-east-1'
WAF_SCOPE = 'CLOUDFRONT'
# 수동으로 지정되어야 하는 변수들
IPV4_SET_NAME = "test" # AWS WAF IP 세트 이름
IPV4_SET_ID = "359fec83-b340-4c16-9bba-7b91f92eb7ee" # AWS WAF IP 세트 ID
INFO_LOGGING = 'false'
def fetch_ip_list_from_url(url: str) -> list[str]:
resp = urllib.request.urlopen(url, timeout=10)
lines = resp.read().decode('utf-8', errors='ignore').splitlines()
# <br /> 제거 및 공백 제거
return [line.replace('<br />', '').strip() for line in lines if line.strip()]
def generate_cidr_from_ip_list(ip_list: list[str]) -> list[str]:
"""비-CIDR 단일 IP 목록을 CIDR(/32)로 정규화.
이미 CIDR이면 그대로 정규화(엄격성 완화). 잘못된 라인은 스킵."""
cidrs: list[str] = []
for raw in ip_list:
try:
# strict=False → 1.2.3.4 → 1.2.3.4/32 로 처리
net = ip_network(raw, strict=False)
# IPv4만 받으려면 아래에서 v4만 필터 가능 (필요 시):
if net.version == 4:
cidrs.append(str(net))
except Exception:
# 잘못된 라인/형식은 스킵
logging.info(f"Skip invalid line: {raw}")
# 중복 제거
return sorted(set(cidrs))
def update_waf_ipset(ipset_name: str, ipset_id: str, addresses: list[str]) -> None:
waf = boto3.client('wafv2', region_name=WAF_REGION)
lock_token = get_lock_token(waf, ipset_name, ipset_id)
waf.update_ip_set(
Name=ipset_name,
Scope=WAF_SCOPE,
Id=ipset_id,
Addresses=addresses,
LockToken=lock_token
)
logging.info(f'Updated IPSet "{ipset_name}" with {len(addresses)} entries')
def get_lock_token(waf, ipset_name: str, ipset_id: str) -> str:
res = waf.get_ip_set(Name=ipset_name, Scope=WAF_SCOPE, Id=ipset_id)
return res['LockToken']
def lambda_handler(event, context):
try:
if len(logging.getLogger().handlers) > 0:
logging.getLogger().setLevel(logging.ERROR)
else:
logging.basicConfig(level=logging.ERROR)
if INFO_LOGGING == 'true':
logging.getLogger().setLevel(logging.INFO)
# 외부 URL에서 IP 주소 목록 가져오기
ip_list = fetch_ip_list_from_url('https://example.com/ip.php')
# 비-CIDR 단일 IP → /32 CIDR로 정규화
cidr_ip_list = generate_cidr_from_ip_list(ip_list)
# AWS WAF IP 세트 업데이트
update_waf_ipset(IPV4_SET_NAME, IPV4_SET_ID, cidr_ip_list)
return {'status': f'IP 세트 업데이트 완료: {len(cidr_ip_list)} entries'}
except Exception as e:
logging.error('에러 발생', exc_info=True)
return {'status': f'에러 발생: {e}'}
운영 팁
- 한 번에 너무 많은 주소를 넣으면 IP set 한도(엔트리 수) 이슈가 날 수 있다. 필요하면 집계/중복제거, 혹은 서브넷 단위로 **요약(aggregate)**해서 엔트리 수를 줄인다.
- CloudFront WAF는 **반드시
us-east-1
**로 호출해야 한다(Scope="CLOUDFRONT"
).
관리 계정 웹서버
index.php : 메인 페이지
<?php
session_start();
if (!isset($_SESSION["username"])) { header("Location: login.php"); exit; }
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WAF IP SET: IP Block 설정</title>
<style>
body{font-family:Arial,sans-serif;background:#f9f9f9;margin:0;padding:0}
.container{max-width:800px;margin:20px auto;padding:20px;background:#fff;border-radius:10px;box-shadow:0 0 10px rgba(0,0,0,.1)}
h1,h2{color:#333;margin-top:0}
.section{margin-bottom:30px}
.btn{background:#007bff;color:#fff;border:none;padding:10px 20px;border-radius:5px;cursor:pointer;transition:.3s}
.btn:hover{background:#0056b3}
ul{list-style:none;padding:0;margin:0}
ul li{margin-bottom:5px}
ul li a{text-decoration:none;color:#007bff}
ul li a:hover{text-decoration:underline}
.file-upload-form{display:flex;align-items:center;margin-bottom:15px}
.file-upload-form input[type="file"]{flex:1;margin-right:10px;padding:8px;border:1px solid #ccc;border-radius:5px}
</style>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script>$(function(){$("#showFilesBtn").click(function(){var w=window.open('get_files.php','_blank');w.focus();});});</script>
</head>
<body>
<div class="container">
<h1>WAF IP SET: IP Block 설정</h1>
<div class="section">
<h2>**** 업로드 파일 설정 ****</h2>
<form action="upload.php" method="post" enctype="multipart/form-data" class="file-upload-form">
<input type="file" name="file" id="fileToUpload">
<input type="submit" value="업로드 실행" class="btn">
</form>
</div>
<div class="section">
<h2>**** 업로드된 파일 리스트 보기 ****</h2>
<button id="showFilesBtn" class="btn">업로드된 파일 리스트 보기</button>
</div>
<div class="section">
<h2>**** IP 리스트 정렬 ****</h2>
<ul><li><a href="ip.php">IP 차단 목록 보기</a></li></ul>
</div>
</div>
</body>
</html>
ip.php
: S3 객체 LastModified 기준 90일 이내 파일만 출력
<?php
// AWS SDK 로드
require 'vendor/autoload.php';
use Aws\S3\S3Client;
use Aws\Exception\AwsException;
// S3 클라이언트 생성
$s3 = new S3Client([
'version' => 'latest',
'region' => 'ap-northeast-2', // AWS 리전 설정
]);
// S3 버킷 이름
$bucketName = 'ipblocklists';
try {
// S3 버킷에서 파일 목록 가져오기
$objects = $s3->listObjectsV2([
'Bucket' => $bucketName,
]);
// 현재 시간에서 90일 전의 타임스탬프 계산
$ninetyDaysAgo = strtotime("-90 days");
// 각 파일의 내용 출력
$contents = '';
foreach ($objects['Contents'] as $object) {
$fileName = $object['Key'];
$lastModified = strtotime($object['LastModified']);
// 파일의 수정 날짜가 90일 이내인 경우에만 출력
if ($lastModified > $ninetyDaysAgo) {
try {
// S3에서 파일 내용 가져오기
$result = $s3->getObject([
'Bucket' => $bucketName,
'Key' => $fileName,
]);
// 파일 내용을 한 줄에 하나씩 추가
$contents .= nl2br(htmlspecialchars($result['Body']->getContents())) . "\n";
} catch (AwsException $e) {
$contents .= "파일 내용을 가져오는 중 오류 발생: " . $e->getMessage() . "\n";
}
}
}
// 파일 내용 출력
echo $contents;
} catch (AwsException $e) {
echo "파일 목록을 가져오는 중 오류 발생: " . $e->getMessage();
}
?>
upload.php
: aws sdk 를 이용한 코드 , s3에 업로드 하기 위한 코드
<?php
require 'vendor/autoload.php';
use Aws\S3\S3Client;
use Aws\Exception\AwsException;
$s3 = new S3Client(['version'=>'latest','region'=>'ap-northeast-2']);
$bucketName = 'ipblocklists';
if (!empty($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK && isset($_FILES['file']['tmp_name'])) {
$fileName = $_FILES['file']['name'];
$filePath = $_FILES['file']['tmp_name'];
try {
$result = $s3->putObject([
'Bucket'=>$bucketName,'Key'=>$fileName,'Body'=>fopen($filePath,'rb'),
'ACL'=>'public-read'
]);
echo "파일이 성공적으로 업로드되었습니다. 업로드된 파일 URL: " . $result['ObjectURL'];
} catch (AwsException $e) { echo "파일 업로드 중 오류 발생: " . $e->getMessage(); }
} else { echo "파일 업로드 중 오류 발생: 파일이 존재하지 않습니다."; }
get_files.php : s3에 업로드된 파일 리스트 불러오기 위한 코드, 파일에대한 대운로드, 삭제버튼 구성되어 있음
<?php
session_start();
if (!isset($_SESSION["username"])) { header("Location: login.php"); exit; }
require 'vendor/autoload.php';
use Aws\S3\S3Client;
$bucketName = 'ipblocklists';
$s3 = new S3Client(['version'=>'latest','region'=>'ap-northeast-2']);
try {
$objects = $s3->listObjectsV2(['Bucket'=>$bucketName]);
echo "<h2>업로드된 파일 리스트</h2><ul>";
foreach ($objects['Contents'] as $object) {
$fileName = $object['Key'];
echo "<li>{$fileName} - <a href='download.php?file={$fileName}'>Download</a> | <a href='delete.php?file={$fileName}'>Delete</a></li>";
}
echo "</ul>";
} catch (AwsException $e) { echo "파일 목록을 가져오는 중 오류 발생: " . $e->getMessage(); }
download.php
<?php
require 'vendor/autoload.php';
use Aws\S3\S3Client;
use Aws\Exception\AwsException;
$bucketName = 'ipblocklists';
$s3 = new S3Client(['version'=>'latest','region'=>'ap-northeast-2']);
if (isset($_GET['file'])) {
$fileName = $_GET['file'];
try {
$result = $s3->getObject(['Bucket'=>$bucketName,'Key'=>$fileName]);
header('Content-Type: ' . $result['ContentType']);
header('Content-Disposition: attachment; filename="' . basename($fileName) . '"');
echo $result['Body'];
} catch (AwsException $e) { echo "파일을 다운로드하는 중 오류 발생: " . $e->getMessage(); }
} else { echo "파일 이름이 제공되지 않았습니다."; }
delete.php
<?php
require 'vendor/autoload.php';
use Aws\S3\S3Client;
$bucketName = 'ipblocklists';
if (isset($_GET['file'])) {
$fileName = $_GET['file'];
$s3 = new S3Client(['version'=>'latest','region'=>'ap-northeast-2']);
try {
$s3->deleteObject(['Bucket'=>$bucketName,'Key'=>$fileName]);
echo "파일이 성공적으로 삭제되었습니다: $fileName";
} catch (\Throwable $e) { echo "파일 삭제 중 오류 발생: " . $e->getMessage(); }
} else { echo "삭제할 파일이 지정되지 않았습니다."; }
login.php : 로그인 폼 을 보여주는 코드
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Login</title></head>
<body>
<h2>Login</h2>
<form action="loginuse.php" method="post">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required><br><br>
<label for="password">Password:</label>
<input type="password" id="password" name="password" required><br><br>
<input type="submit" value="Login" name="submit">
</form>
</body>
</html>
loginuse.php : 로그인 과정을 처리하는 실제 코드 (id,password 하드코딩되어 있음)
<?php
$username = "test@example.com";
$password = "password";
if ($_SERVER["REQUEST_METHOD"] == "POST") {
$input_username = $_POST["username"];
$input_password = $_POST["password"];
if ($input_username === $username && $input_password === $password) {
session_start();
$_SESSION["username"] = $username;
header("Location: index.php"); exit;
} else {
echo "<p>Invalid username or password. Please try again.</p>";
}
}
AWS 환경 및 보안 주의
- 이 가이드는 참고만 하셔서 적당한 응용을 했으면 한다.
그대로 써도 되지만, S3 ACL / WAFv2 위치등 환경이 다르면 여러분 환경에 맞게 조정이 필요하다.loginuse.php
에 아이디/비밀번호가 하드코딩되어 있다. 실서비스에선 가능하면 환경변수/매개변수 스토어/SSO 등으로 분리하고, HTTPS 강제+세션 하드닝을 권장.
체크리스트
- S3 ObjectCreated 트리거 → Lambda
- EventBridge(UTC 00:12) → Lambda(90일 보정 전체 집계) : 한국시간 09:12분
- Lambda 설정:
Region us-east-1
,Scope CLOUDFRONT
- Lambda VPC Outbound 443 허용, 웹 EC2 Inbound 443 허용(출발지 NAT GW)
- ip.php: 90일 이내 파일만 줄단위(IP 또는 CIDR) 출력
- 관제 포맷에 따라 케이스 A(이미 CIDR) 또는 케이스 B(비-CIDR → /32 변환) Lambda 사용
ⓒ 2025 엉뚱한 녀석의 블로그 [quirky guy's Blog]. 본문 및 이미지를 무단 복제·배포할 수 없습니다. 공유 시 반드시 원문 링크를 명시해 주세요.
ⓒ 2025 엉뚱한 녀석의 블로그 [quirky guy's Blog]. All rights reserved. Unauthorized copying or redistribution of the text and images is prohibited. When sharing, please include the original source link.
ⓒ 2025 엉뚱한 녀석의 블로그 [quirky guy's Blog]. All rights reserved. Unauthorized copying or redistribution of the text and images is prohibited. When sharing, please include the original source link.
답글 남기기
댓글을 달기 위해서는 로그인해야합니다.