봇/해킹 탐지 — Spring 예외 처리로 Slack 알림 보내기
Spring Boot 서버를 운영하다 보면 워드프레스 취약점 스캔, multipart POST 프로브 등
봇의 공격 시도가 끊임없이 들어온다. 이를 실시간으로 Slack 알림으로 받는 방법을 설명한다.
기존 예외 처리와의 차이
Spring Boot 예외 처리와 보안 알림 문서에서는
봇이 발생시키는 404/405는 알림 없이 처리하는 패턴을 다뤘다.
이 문서는 한 단계 더 나아가 탐지 가치가 있는 공격 시도를 Slack으로 능동적으로 알린다.
| 구분 | 처리 방식 |
|---|---|
| 일반 404 (잘못된 URL) | 무시, 알림 없음 |
| 의심 경로 404 (wp-login, .env 등) | 보안 알림 발송 |
| multipart POST 프로브 | 보안 알림 발송 |
| 진짜 서버 오류 (500) | 서버 오류 알림 발송 |
SecurityAlertService — 핵심 컴포넌트
보안 탐지 로직을 GlobalExceptionHandler에서 분리해 별도 서비스로 관리한다.
@Slf4j
@Service
@RequiredArgsConstructor
public class SecurityAlertService {
private final SlackService slackService;
private static final long COOLDOWN_MS = 10 * 60 * 1000L; // 10분
private static final List<String> SUSPICIOUS_PATTERNS = List.of(
"wp-", "wordpress", "phpinfo", ".env", ".git", ".aws",
"actuator", "cgi-bin", "shell", "eval", "xmlrpc", "jndi"
);
private final ConcurrentHashMap<String, Long> lastAlertByIp = new ConcurrentHashMap<>();
public void reportAttack(HttpServletRequest request, String attackType) {
String ip = extractClientIp(request);
if (isOnCooldown(ip)) {
log.debug("보안 알림 억제 (cooldown): ip={}", ip);
return;
}
markAlerted(ip);
slackService.sendSecurityAlert(ip, request.getMethod(),
request.getRequestURI(), attackType);
}
public boolean isSuspiciousPath(String path) {
String lower = path.toLowerCase();
return SUSPICIOUS_PATTERNS.stream().anyMatch(lower::contains);
}
private String extractClientIp(HttpServletRequest request) {
String xff = request.getHeader("X-Forwarded-For");
return (xff != null && !xff.isBlank()) ? xff.split(",")[0].trim()
: request.getRemoteAddr();
}
private boolean isOnCooldown(String ip) {
Long last = lastAlertByIp.get(ip);
return last != null && System.currentTimeMillis() - last < COOLDOWN_MS;
}
private void markAlerted(String ip) {
// 1000개 초과 시 자동 정리
if (lastAlertByIp.size() > 1000) lastAlertByIp.clear();
lastAlertByIp.put(ip, System.currentTimeMillis());
}
}
GlobalExceptionHandler 적용
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
private final SlackService slackService;
private final SecurityAlertService securityAlertService;
// multipart POST 프로브 → 400 + 보안 알림
@ExceptionHandler(MultipartException.class)
public Object handleMultipart(MultipartException e, HttpServletRequest request,
HttpServletResponse response) {
securityAlertService.reportAttack(request, "multipart 파일 업로드 스캔");
return ResponseEntity.badRequest().body(ApiResponse.fail("잘못된 요청입니다."));
}
// 존재하지 않는 경로 — 의심 경로만 알림
@ExceptionHandler(NoResourceFoundException.class)
public Object handleNoResource(NoResourceFoundException e, HttpServletRequest request,
HttpServletResponse response) {
if (securityAlertService.isSuspiciousPath(request.getRequestURI())) {
securityAlertService.reportAttack(request, "취약점 경로 스캔");
}
return ResponseEntity.notFound().build();
}
// 진짜 서버 오류 → 500 + 서버 오류 알림 (보안 알림 아님)
@ExceptionHandler(Exception.class)
public Object handleGeneral(Exception e, HttpServletRequest request,
HttpServletResponse response) {
slackService.sendError(request.getRequestURI(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.fail("서버 오류가 발생했습니다."));
}
}
SlackService — 보안 알림 포맷 분리
서버 오류 알림과 보안 알림을 별도 메서드로 분리해 수신자가 구분할 수 있게 한다.
// 진짜 서버 오류 알림
public void sendError(String requestUri, Throwable ex) {
String message = """
*[500 에러]* 서버 오류 발생
• 경로: `%s`
• 예외: `%s`
• 메시지: %s""".formatted(requestUri, ex.getClass().getSimpleName(), ex.getMessage());
send(message);
}
// 보안 탐지 알림 (별도 포맷)
public void sendSecurityAlert(String clientIp, String method, String uri, String detail) {
String message = """
*[보안 탐지]* 봇/해킹 시도
• IP: `%s`
• 요청: `%s %s`
• 유형: %s""".formatted(clientIp, method, uri, detail);
send(message);
}
포인트 정리
- IP 추출:
X-Forwarded-For헤더로 Nginx 뒤의 실제 클라이언트 IP 추출 - 쿨다운: 같은 IP가 10분 내 반복 공격해도 Slack 알림은 1회만 발송
- 자동 정리:
ConcurrentHashMap항목이 1000개 초과 시 전체 초기화로 메모리 보호 - 알림 분리: 서버 오류(
sendError)와 보안 탐지(sendSecurityAlert) 포맷 구분
수신 알림 예시
[보안 탐지] 봇/해킹 시도
• IP: 198.51.100.10
• 요청: POST /
• 유형: multipart 파일 업로드 스캔
[보안 탐지] 봇/해킹 시도
• IP: 198.51.100.20
• 요청: GET /wp-login.php
• 유형: 취약점 경로 스캔