AWS Lambda로 WAF IP 차단 리스트 자동화 (S3+EventBridge, CloudFront WAFv2)

예전 금융권 관련 회사에서 인프라/보안 팀장을 맡았을 때, 관제사에서 주기적으로 주는 차단 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 set Addresses에 넣으면 된다.
  • 케이스 B: 비-CIDR 단일 IP로 제공 (예: 203.0.113.5, 198.51.100.10)
    → WAF는 CIDR만 받으므로 x.x.x.x/32로 정규화해서 반영해야 한다.

현장에서 관제 포맷이 바뀌거나 공급자가 여러 곳이면, 두 케이스를 각각 지원하면 운영이 편하다. (같은 Lambda 안에서 자동 판별도 가능하지만, 여기선 명시적으로 두 버전을 준비해 안전하게 구분 운영한다.)


전체 흐름

  1. 관리 계정 웹서버: .txt 업로드 →
  2. 운영 계정 S3 (ipblocklists) 저장 →
  3. S3 ObjectCreated 트리거 → 운영 계정 LambdaWAF IP set 업데이트
  4. 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.phpx.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.41.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.