필자가 운영하고 있는 이 블로그의 출발은 cybercafe.tistory.com 이라는 도메인 주소를 가진 티스토리였다. 그 후 2013년에 현재의 도메인(blogger.pe.kr)을 구입하여 티스토리에 2차 도메인으로 연결했다. 그리고 3년 전 티스토리에서 독립한 후 티스토리 도메인은 삭제하였다.
너무도 많은 유해 트래픽
독립 서버에서 블로그를 운영하다 보니 대기업의 블로그 서비스를 이용할 때는 알 수 없던 유해트래픽을 온몸으로 느끼고 있다. 실제 검색엔진을 통해 들어오는 “사람”의 방문 트래픽보다 Bot이나 해킹 시도 트래픽은 10배는 더 많다. 아니 실제로 트래픽을 분석해보면 그 이상일지도 모르겠다.
• 서버 운영체제의 관리자 권한을 노리고 들어오는 SSH 접속 시도(brute force 등)와 DB 접속 시도
• 워드프레스 블로그의 사용자 로그인 페이지에서의 로그인 시도
• 알려진 취약점을 노리고 그냥 찔러보는 스캐너와 해킹 봇들
• 게다가 글을 퍼가려는 Bot과 크롤러 등등등
먼저 .htaccess 나 .conf 에서 방문하기를 원하지 않는 봇의 접근을 차단했다. (보러가기) 그리고 직접 만들어 붙여 넣은 방문자 카운터의 로그를 보고서 알게 된 대부분 중국 IDC가 발원지이며 Headless 브라우저 엔진을 사용해 Java Script까지 실행하는 악랄한 크롤러와 봇은 iptable + ipset 조합으로 차단했다. (보러가기) 그리고 SSH, 워드프레스 관리자 접속 공격, 취약점 스캐너의 접근은 iptable + ipset 조합에 더해 Fail2Ban 도구를 활용하는 미니 웹방화벽을 구현해 차단했다. (보러가기)
마지막으로 남은 것은 브라우저인척~ 위장해서 Java Script와 UI렌더링은 하지 않으면고 정적인 컨텐츠만 크롤링해가거나 운영자가 느끼지 못하게 취약점을 스캐닝하는 목적으로 사용되는 가볍지만 가장 많은 트래픽을 유발하는 스크립트 기반의 봇과 크롤러들이다.
JS Challenge로 스크립트 기반 봇을 차단한다
웹 서핑을 하다 보면 아래 화면과 같이 처음 접속할 때 곧바로 컨텐츠가 있는 URL로 이동하지 않고 모래시계 처럼 뭔가가 나타나 빙글~빙글~ 돌다가 일정 시간이 지나면 화면이 이동하면서 방문하려 했던 페이지로 진입되는 경험을 하게 된다.

이런 화면을 Java Script Challenge (줄여서 JS 챌린지) 라고 한다. 이런 화면이 보이는 이유가 바로 사람이 아닌 Bot 이나 Crawler가 방문하는 것은 아닌지 먼저 확인하기 위해서다. 위의 화면은 웹서버에 직접 적용한 것이 아니라 클라우드 플레어 서비스를 활용해 WAF에서 적용한 것이다.
이 JS Challenge의 원리는 매우 단순하다.
대부분의 Bot 이나 Crawler는 동시에 많은 웹사이트에 접근하기 위해 최대한 CPU와 Memory를 절약해야 하며 그로 인해 Java Script를 실행하지 않는 Python, PHP, Perl 등의 스크립트를 기반으로 동작한다는 것에 착안하여 웹 사이트 방문자에게 작은 Java Script를 제일 먼저 실행하고 그 결과를 회신하면 쿠키에 토큰을 설정해주고 이후 일정 시간 동안 확인 절차를 거치지 않게 해주는 것이다.
워드프레스 블로그에 JS Challenge 적용하기
클라우드 플레어를 활용해 JS Challenge 를 적용하는 방법도 있지만 단독서버로 운영하고 있는 워드프레스에 JS Challenge를 적용하는 것은 매우 쉽다. 다음과 같은 코드를 먼저 워드프레스의 wp-config.php가 있는 경로에 생성한다. 이 코드는 AI의 도움을 받아 필자가 필요로 하는 기능만을 넣은 가벼운 JS Challenge 코드다. 필자는 워드프레스에 적용했지만 Apache 2로 구축한 일반 웹사이트에도 적용할 수 있다. (nginx 에서는 테스트 되지 않았음)
<?php
// ---- 악성봇 차단 용 JS Challenge Code
// --- [1. 로깅 설정] 날짜별 로그 파일 생성 ---
function log_bot_activity($ip, $user_agent, $reason) {
date_default_timezone_set('Asia/Seoul');
// 오늘 날짜를 구해서 파일명에 붙입니다. (예: bot-shield-2026-05-24.log)
$date = date('Y-m-d');
$time = date('Y-m-d H:i:s');
$log_file = __DIR__ . "/bot-shield-{$date}.log";
// 원본 대소문자가 유지된 User-Agent를 기록하기 위해 원본을 가져옵니다.
$raw_ua = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : 'Unknown';
$log_message = "[{$time}] IP: {$ip} | 사유: {$reason} | UA: {$raw_ua}\n";
// 파일에 로그를 씁니다. (@ 기호를 붙여 권한 에러 시 사이트가 멈추지 않게 방지)
@file_put_contents($log_file, $log_message, FILE_APPEND);
}
// --- [2. 사전 통과] 이미 검증된 일반 방문자 (쿠키 보유) ---
if (isset($_COOKIE['verified_human']) && $_COOKIE['verified_human'] === '1') {
goto SKIP_CHALLENGE;
}
// 내 서버가 스스로에게 보내는 내부 요청(루프백) 무조건 통과
// 127.0.0.1(로컬), ::1(IPv6 로컬), 그리고 JS Challenge를 하지 않을 서버의 퍼블릭 IP를 등록
$my_server_ips = ['127.0.0.1', '::1', '123.123.123.123'];
$ip = $_SERVER['REMOTE_ADDR'];
// $_SERVER['SERVER_ADDR']는 현재 서버의 IP를 자동으로 가져옵니다.
$server_addr = isset($_SERVER['SERVER_ADDR']) ? $_SERVER['SERVER_ADDR'] : '';
if (in_array($ip, $my_server_ips) || $ip === $server_addr) {
// 자기 자신에게 접근하는 것은 확인없이 통과
goto SKIP_CHALLENGE;
}
$ip = $_SERVER['REMOTE_ADDR'];
// 검사를 위해 User-Agent를 무조건 소문자로 변환
$user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? strtolower($_SERVER['HTTP_USER_AGENT']) : '';
// --- SEO를 위해 PageSpeed Insights의 접근은 예외 처리 (rDNS 없이 통과)
if (strpos($user_agent, 'chrome-lighthouse') !== false || strpos($user_agent, 'ptst') !== false) {
goto SKIP_CHALLENGE;
}
// --- [3.rDNS 검증 또는 rDNS가 없는 bot 중 허용할 봇 정의 ]
$is_search_bot = false;
$allowed_domains = [];
// 구글 (Googlebot, GoogleOther) - 구글봇으로 위장해 접근하는 것을 차단하기 위해 rDNS 검증함.
if (strpos($user_agent, 'googlebot') !== false || strpos($user_agent, 'googleother') || strpos($user_agent, 'mediapartners-google') !== false) {
$is_search_bot = true;
$allowed_domains = ['.googlebot.com', '.google.com'];
// 빙 (Bingbot)
} elseif (strpos($user_agent, 'bingbot') !== false || strpos($user_agent, 'bing.com') !== false) {
$is_search_bot = true;
$allowed_domains = ['.search.msn.com'];
// 네이버 (Yeti, NaverBot) - 인앱 브라우저 충돌 방지
} elseif (strpos($user_agent, 'yeti') !== false || strpos($user_agent, 'naverbot') !== false) {
$is_search_bot = true;
$allowed_domains = ['.naver.com'];
// Apple (Applebot)
} elseif (strpos($user_agent, 'applebot') !== false) {
$is_search_bot = true;
$allowed_domains = ['.apple.com'];
// DuckDuckBot - rDNS를 등록하지 않은 허용 Bot의 경우 rDNS를 적용하지 않고 통과
} elseif (strpos($user_agent, 'duckduckbot') !== false) {
goto SKIP_CHALLENGE;
// 페이스북 & 인스타그램 (Open Graph)
} elseif (strpos($user_agent, 'facebookexternalhit') !== false || strpos($user_agent, 'meta-externalagent') !== false) {
goto SKIP_CHALLENGE;
// 카카오톡 (Open Graph) - rDNS가 없으므로 도메인 검증 없이 바로 통과시킴
} elseif (strpos($user_agent, 'kakaotalk-scrap') !== false || strpos($user_agent, 'kakaolink') !== false) {
goto SKIP_CHALLENGE;
// Naver blog RSS bot - rDNS가 없으므로 도메인 검증 없이 바로 통과시킴
} elseif (strpos($user_agent, 'naver blog rssbot') !== false || strpos($user_agent, 'rssbot') !== false) {
goto SKIP_CHALLENGE;
// Open AI Serach Bot
} elseif (strpos($user_agent, 'oai-searchbot') !== false && strpos($user_agent, 'openai.com') !== false) {
goto SKIP_CHALLENGE;
// Feedly. ip Manual compare - 만약 rDNS를 지원하지 않는 봇을 위장해 접근하는 것으로 의심될 경우 수동으로 IP 대역을 등록해 IP를 검사함
} elseif (strpos($user_agent, 'feedly.com') !== false && strpos($user_agent, 'poller.html') !== false) {
$feedly_ips = [
'68.29.198.2',
// '125.209.200.' // (예시) 나중에 다른 대역이 추가로 발견되면 여기에 한 줄씩 추가
];
$is_valid_rss_bot = false;
foreach ($feedly_ips as $allowed_ip_prefix) {
if (strpos($ip, $allowed_ip_prefix) === 0) {
$is_valid_rss_bot = true;
break;
}
}
if ($is_valid_rss_bot) {
goto SKIP_CHALLENGE; // 성공
} else { // 검증 실패
log_bot_activity($ip, $user_agent, "Faked Feedly Bot is Denied.");
header('HTTP/1.1 403 Forbidden');
die('Access Denied: Fake Feedly RSS Bot');
}
}
// --- [4.rDNS (역방향 DNS) 교차 검증하는 봇인경우. 유명 봇으로 위장해 접근하는 경우 rDNS를 사용해 출발지 IP를 검증함 ---
if ($is_search_bot) {
$hostname = gethostbyaddr($ip);
$domain_match = false;
foreach ($allowed_domains as $domain) {
// 호스트네임이 허용된 도메인(.google.com 등)으로 끝나는지 확인
if (substr($hostname, -strlen($domain)) === $domain) {
$domain_match = true;
break;
}
}
if ($domain_match) {
$resolved_ip = gethostbyname($hostname);
if ($resolved_ip === $ip) {
// 모든 검증을 통과한 "진짜" 봇
goto SKIP_CHALLENGE;
}
}
// 검증에 실패했다면 이름을 훔친 "가짜 봇"입니다.
log_bot_activity($ip, $user_agent, "가짜 봇 적발 (차단됨) 또는 rDNS 실패");
header('HTTP/1.1 403 Forbidden');
die('Access Denied: Fake Search Bot Detected');
}
// --- [5. JS 챌린지] 일반 방문자 인지 봇인지 검증, 봇인지 사용자인지를 밝히지 않고 User Agent를 사용자인 것으로 위장했는지 검증 ---
log_bot_activity($ip, $user_agent, "JS 챌린지 발급");
// 챌린지 화면을 띄우기 전에 원래 Referer를 임시 쿠키에 저장
$current_referer = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : '';
// 내 블로그 내부에서 이동한 것이 아니라, 외부에서 처음 유입되었을 때만 3분간 저장
if ($current_referer !== '' && strpos($current_referer, 'blogger.pe.kr') === false) {
setcookie('original_referer', $current_referer, time() + 180, '/');
}
header('HTTP/1.1 503 Service Temporarily Unavailable');
header('Retry-After: 5');
header('Cache-Control: no-cache, must-revalidate');
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>브라우저 환경 검증 중...</title>
<style>
body { font-family: sans-serif; text-align: center; padding-top: 50px; color: #333; }
.loader { border: 4px solid #f3f3f3; border-top: 4px solid #3498db; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 20px auto; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
</style>
<script>
// 진짜 JS Challenge Code
setTimeout(function() {
// 단순 연산 테스트 만으로도 Bot은 걸러질 수 있음. JavaScript를 실행하지 못하므로
var challenge = 5 * 2;
// 계산에 성공하면12시간 동안 유지되는 쿠키 생성
if (challenge === 10) {
document.cookie = "verified_human=1; path=/; max-age=43200; SameSite=Lax";
// 원래 접속하려던 URL로 새로고침
window.location.reload(true);
}
}, 200); // 0.2초 대기. 몇 초씩 기다리게 할 수도 있음
</script>
</head>
<body>
<h2>안전한 접속을 위해 브라우저를 확인하고 있습니다.</h2>
<div class="loader"></div>
<p>잠시만 기다려주시면 자동으로 페이지가 이동됩니다.</p>
</body>
</html>
<?php
// 워드프레스 코어가 실행되지 않도록 여기서 실행 종료
exit;
SKIP_CHALLENGE:
// 임시 저장해둔 원래 유입 경로를 서버 변수에 복원
if (isset($_COOKIE['original_referer'])) {
// 워드프레스가 켜지기 전에 Referer 변수를 원래 주소로 바꿔치기
$_SERVER['HTTP_REFERER'] = $_COOKIE['original_referer'];
// 복원을 완료했으므로 다 쓴 임시 쿠키는 즉시 삭제
setcookie('original_referer', '', time() - 3600, '/');
}kk
코드가 길어 보이지만 꽤 단순하다. 이 코드를 wp-js-challenge.php 등 원하는 이름으로 저장한다.
그리고 wp-config.php 파일의 최 상단에 다음과 같이 require_once 명령으로 실행되게 해준다.

그리고 확실하게 하기 위해 Apache 2 웹서버를 재기동 해주면 작업은 끝난다. 그리고 다음과 같이 블로그에 방문하면 JS챌린지 화면이 표시된다.

필요하다면 사람이 직접 체크버튼을 “클릭”해야 하도록 적용할 수도 있지만 필자는 필요가 없어 적용하지 않았다. 헤드리스 브라우저를 통한 접속을 차단하기 위해서는 체크 또는 캡차 적용이 필요하지만 필자의 블로그는 iptable과 ipset의 조합을 통해 서버 방화벽 레이어에서 원천 차단하도록 룰을 관리하고 있기 때문이다.
JS챌린지 로그
위의 JS챌린지 코드에는 로그파일 기능을 적용했다. 하루 하나씩 로그 파일이 생성되도록 구현했다. 혹시라도 차단되면 안되는 bot, 예를 들자면 구글 검색엔진 봇에게 JS챌린지를 시도하면 구글 봇은 새로 발행된 포스트를 읽어가지 못하는 참사가 발생할 수 있다. 그래서 어떤 봇이 차단되는지 확인해야 하기 때문에 JS챌린지가 적용되는 방문자의 IP와 User Agent 정보를 다음과 같이 기록하도록 했다.

일정기간 이 로그 파일을 모니터링하면서 잘못 차단되는 유익한 Bot은 없는지 검토해야 한다.
문제점
JS챌린지를 적용한지 하루만에 문제가 발생했다. 바로 직접 코딩하여 문든 방문자 카운터로그에서 Referer 주소가 모두 방문한 주소와 동일한 주소로 수집되는 문제였다.

이 문제의 원인은 방문자 카운터의 동작 방식에 있었다. 방문자 카운터는 Java Script를 방문자의 브라우저에서 실행하여 document.referer를 서버로 전송하기 때문에 위의 코드에서 아무리 쿠키에 방문 전 주소 (Referer 주소)를 저장하고 참조하더라도 의미가 없는 것이다.
그래서 방문자 카운터의 코드를 수정해 미리 저장해둔 Referer 주소가 있으면 document.referer 의 주소가 아닌 쿠키에 저장되어 있는 referer 주소를 전송하도록 수정해 적용하여 문제를 해결했다.
#JS챌린지 #워드프레스
답글 남기기