안물안궁 하시는 분들은 바로 다음 목차로 가시면 됩니다!
중요한 내용 없습니다 😅
이번에 회사에서 사이트 기능 중에서 모의해킹 : 자동화 공격
과 관련된
문제가 제기되어서 이를 막는 작업을 했습니다.
자동화 공격은 주로 시스템(또는 DB)에 데이터가 무작위로 증가시키는 요청을
막는 것이 핵심입니다. HTTP 요청으로는 일반적으로 POST 요청에 해당하는 것들이죠.
모의해킹을 해주신 업체에서는 이에 대한 대처 방안으로 3가지를 제시했는데요.
CSRF
토큰Catpcha
이전에 3. 특정 IP(또는 사용자)의 요청을 일정시간 막기
와 관련해서 이미 방법을 공유
한적이 있었습니다. 실제로 동작도 잘됐구요 😊
다만 여러 예상못한 상황이 겹치다보니,
어쩔 수 없이 한 가지 방법을 더 사용하게 되었습니다.
저는 남은 1,2 번 방법 중 하나인 2번, Captcha 방식을 사용하기로 했습니다.
지금부터 제가 Captcha 를 적용했던 방식을 공유합니다.
참고
: 어떤 글들에서는 Captcha 적용을 위해서Naver
API 를 쓰더군요.
저는 딱히 그럴 필요가 없다 생각하여 그냥 Captcha 라이브러리를 사용했습니다.
저는 어떤 라이브러리를 찾아다닐 때 무조건 maven repository 에 가서
키워드로 먼저 검색을 해보는 편입니다. captcha
로 검색해보니 아래처럼 나오더군요.
위처럼 여러가지가 나오면 2번째로는 보는 것이 Last Release Date
입니다.
Hutool Captcha
가 가장 최근에 Release 한 걸 확인할 수 있습니다.
그래서 Hutool Captcha
를 사용하기로 했습니다.
추가로 Hutool 과 관련된 개발 문서도 상당히 잘되어
있어서 여러모로 도움이 많이 됐던 라이브러리입니다.
(중국어이긴 하지만 chatgpt 와 함께라면 무섭지 않습니다 😊)개발문서 링크: https://doc.hutool.cn/pages/captcha/
문의하기 등록 (POST /qna)
라는 URL 요청이 있다고 합시다.Captcha 코드 이미지
를 반환하고,Captcha 코드
를 암호화하여 같이 전송합니다.Frontend 에서는 받은 이미지를 화면에 표출하고,
사용자들이 해당 이미지 위의 코드값을 입력하도록 합니다.
사용자가 코드값을 모두 입력하고 등록
버튼을 클릭하면
이때 javascript
에서 POST /qna
요청을 보낼 때,
해당 요청의 Http Header
에 해당 코드값을 넣습니다.
요청이 Tomcat 에 오게 되면 이미 등록된 Filter 들을 거치는데,
이때 미리 만들어둔 캡챠 코드 확인용 Filter 가 해당 URL
이
captcha 검사가 필요한 요청인지 확인합니다.
만약 캡챠 코드 확인이 필요한 요청이면,
해당 요청의 Http Header
에서 사용자가 입력한 캡챠 코드를 추출합니다.
그리고 3 번에서 생성했던 쿠키에서 암호화된 Captcha 코드
를 추출하여
복호화를 합니다.
최종적으로 이렇게 Http Header
와 Cookie
에서 뽑아낸 값을 비교합니다.
일치한다면 성공 기존 API 요청으로 그대로 수행하고,
실패하면 검사 Filter 내부에서 실패 Response 를 생성해서 반환합니다.
Captcha 코드/이미지 생성 Controller 입니다.
Http Body 로는 캡챠 코드 이미지를 넣고,
enc-captcha-code
라는 이름의 쿠키에 암호화된 캡챠 코드를 넣어서
같이 Http Response 로 Client 에게 반환합니다.
/*
참고: 개발 환경
OS : Window 10 Home
Servlet Engine : Tomcat 10.x.x `->` jakarta 패키지 사용!
java : azul-jdk (v.21)
*/
import cn.hutool.captcha.LineCaptcha;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import me.daily.code.util.CryptoHelper;
@RestController
@RequiredArgsConstructor
public class CaptchaController {
// CryptoHelper 는 아주 단순한 안복호화 유틸입니다.
// java 암복호화라고 검색하면 금방 나오니 이건 스스로 해보시기 바랍니다!
private final CryptoHelper cryptoHelper;
@GetMapping("/captcha")
public ResponseEntity<byte[]> getCaptchaImg(/*HttpServletResponse response*/) {
// 캡챠 이미지 생성
LineCaptcha lineCaptcha = new LineCaptcha(
300, // 이미지 너비
100, // 이미지 높이
6, // 캡챠 코드의 수(=문자열 길이)
30 // 간섭선 수(이미지 주변을 더럽히는 선의 수)
);
// 코드 생성
lineCaptcha.createCode();
// 캡챠 코드 값을 암호화한다.
String encodedCaptchaCode =
cryptoHelper.encrypt(lineCaptcha.getCode());
// 암호화한 캡챠 코드를 쿠키에 넣고 전송한다.
// 방법1: HttpServletResponse 에 적용
// final Cookie cookie = new Cookie("enc-captcha-code", encodedCaptchaCode);
// cookie.setSecure(true);
// cookie.setHttpOnly(true);
// response.addCookie(cookie);
// 방법2: Spring 에서 제공하는 기능 사용
ResponseCookie responseCookie = ResponseCookie
.from("enc-captcha-code", encodedCaptchaCode)
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.build();
// HttpHeader 를 세팅한다.
HttpHeaders headers = new HttpHeaders();
String captchaImgName = "captcha_code.png";
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + captchaImgName + "\"");
headers.add(HttpHeaders.SET_COOKIE, responseCookie.toString());
// Client 에게 HTTP Body 로는 캡챠 이미지를 보내고, Cookie 로는 암호화된 캡챠코드를 준다.
return ResponseEntity.ok()
.headers(headers)
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(lineCaptcha.getImageBytes());
}
// 캡챠 코드 테스트용 Post 요청입니다.
@PostMapping("/test/captcha")
public String testCaptcha() {
return "good job";
}
}
enc-captcha-code
라는 이름의 Cookie 로한번 PostMan 으로 테스트를 해봤습니다.
이미지를 다운로드 요청을 보내고 나서, 실제 파일을 다운로드 받습니다.
이후에 이미지 응답과 함께 반환된 Cookie 도 확인합니다.
enc-captch-code
라는 이름의 쿠키가 정상적으로 반환된 것을 확인할 수 있습니다.
그리고 값도 암호화가 잘되어 있는 것도 확인이 됩니다.
사용자가 입력한 캡챠코드와 쿠키에 있는 암호화된 캡챠코드를 비교하여
일치하는 지를 판단하는 필터입니다.
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.filter.OncePerRequestFilter;
import me.daily.code.util.CryptoHelper;
import java.io.IOException;
import java.util.List;
/**
* 캡챠 코드를 반환하고, 캡챠코드를 검토하는 필터다.<br>
* [ mustCheckUrlList 에 명시된 URL + POST METHOD ] 요청에 대해서는 HTTP 헤더로 [X-CHECK-CAPTCHA=캡챠_코드값] 값을 받는다.<br>
*/
@Slf4j
@RequiredArgsConstructor
public class CaptchaCodeCheckFilter extends OncePerRequestFilter {
private final List<String> mustCheckUrlList = List.of(
"/test/captcha" // CaptchaCodeCheckFilter 에서 만든 테스트용 Post 요청
);
private final CryptoHelper cryptoHelper;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String requestURI = request.getRequestURI();
String method = request.getMethod();
// 체크해야될 URL 이면서 POST 메소드에 대한 것만 막는다.
if (!mustCheckUrlList.contains(requestURI) || !"POST".equals(method)) {
filterChain.doFilter(request, response);
return;
}
// CaptchaController 에 의해서 생성된 암호화된 캡챠 코드를 갖는 Cookie 를 조회한다.
Cookie[] cookies = request.getCookies();
String findingCookieName = "enc-captcha-code";
Cookie cookie = getCaptchaCookie(cookies, findingCookieName);
// 만약 조회되는 쿠키가 없다면, 이는 먼저 캡챠 코드를 생성하는 [GET /captcha] 요청을 안 한 것이다.
// ERROR 결과를 반영하여 return 한다.
if(cookie == null) {
log.error("ENC-CAPTCHA-CODE 쿠키가 발견되지 않았습니다! [GET /captcha] 요청을 혹시 실행 안하셨나요?");
errorHttpResponseConfig(response, "서버에서 먼저 캡챠 코드를 받은 후 실행하시기 바랍니다.", requestURI);
return;
}
// Client 가 전송 시에 입력한 캡챠값을 조회한다. 참고로 해당 값은 헤더에 있어야 한다.
String clientInputCaptchaCode = request.getHeader("X-CHECK-CAPTCHA");
if(clientInputCaptchaCode == null) {
log.error("HTTP HEADER => X-CHECK-CAPTCHA 값이 발견되지 않았습니다!");
errorHttpResponseConfig(response, "캡챠코드를 전송하지 않았습니다." , requestURI);
return;
}
// 쿠키에 들어 있는 암호화된 캡챠 코드를 복호화한다.
String encodedCaptchaCode = cookie.getValue();
String originalCaptchaCode =
cryptoHelper.decrypt(encodedCaptchaCode);
// Client 가 보낸 캡챠 코드와 쿠키에서 추출한 캡챠코드를 비교한다.
if (!clientInputCaptchaCode.equals(originalCaptchaCode)) {
errorHttpResponseConfig(response, "캡챠 코드가 틀렸습니다." , requestURI);
return;
} else {
// 정상적으로 캡챠코드가 확인됐으면, 해당 쿠키를 지운다.
cookie.setMaxAge(0);
response.addCookie(cookie);
}
filterChain.doFilter(request, response);
}
private void errorHttpResponseConfig(HttpServletResponse response, String errorMsg, String requestURI) throws IOException {
response.setCharacterEncoding("UTF-8");
response.setHeader("Content-Type", "application/json");
String errorResponseJson = createErrorResponseJson(errorMsg ,requestURI);
response.getWriter().write(errorResponseJson);
}
private Cookie getCaptchaCookie(Cookie[] cookies, String findingCookie) {
if(cookies == null) return null;
for (Cookie cookie : cookies) {
if(cookie.getName().equals(findingCookie)) return cookie;
}
return null;
}
private String createErrorResponseJson(String errorMsg, String requestURI) {
return """
{
"code":"%s",
"message":"%s",
"result":"%s"
}
""".formatted(
999,
errorMsg,
String.format("[%s] 요청이 블록되었습니다.", requestURI)
).trim();
}
}
enc-captcha-code
이름의 쿠키에서 암호화된 캡챠 코드를 추출하여,PostMan 에서 아래처럼 캡챠 이미지 위의 코드값을 Http Header(X-CHECK-CAPTCHA
)
에 넣고 전송해서 테스트해보시기 바랍니다. 성공한다면 "good job" 이라는 문자열을 반환 받습니다.
(참고로 POST /test/captcha
는 CaptchaCodeCheckFilter
에 만든 테스트용 URL)
끝입니다.