작업일지

realstatelab.com | HTTP 로그로 행동패턴을 분석하던 중 발견한 이상한 요청들

hj.choi 2025. 3. 18. 16:56

개요

25년 3월 4일, 백엔드 개발자로서 근무하며 배운 내용들을 되짚어볼겸. 기획부터 디자인, 개발까지 주체적으로 누군가에게 도움이 될 수 있는 서비스를 개발해보고 싶은 생각에 'realstatelab.com'을 출시하게 되었습니다. 아직 개발 초기라서 그런지 다소 엉성한 부분이 있어 다양한 시행착오가 있었는데, 그중에 가장 인상깊었던 사례들을 틈틈이 공유해보고자 합니다. 이번 게시글에서는 Http 로그를 남기는 과정에서 알게된 문제 상황과 그 문제를 해결한 과정을 소개하려고 합니다.

 

HTTP 로그를 남긴 이유?

처음에는 단순히 사용자의 행동 패턴을 분석하여, 사용자 맞춤 추천 매물 데이터를 제공하는 기능 개발에 필요한 자료로서 로그를 남기기로 했습니다. 작은 규모의 서비스이기에 별도의 로깅 서비스를 사용하지 않고, DB 테이블을 만들고 별도의 필터를 만들어 http 통신이 발생할때마다, 해당 통신의 요청과 응답을 기록하는 기능을 만들어 빠르게 배포하고 새벽에 잠을 청했습니다.

그런데, 아침에 일어나서 로그를 확인해보니 의도치 않은 로그들이 기록되어 있었습니다. 원래대로라면 아래와 같은 패턴을 보여야 했습니다. (이미지들이 좀 작습니다.. 클릭해서 보면 원본 사이즈로 확인하실 수 있습니다..)

원래는 이랬어야 했는데...

위 데이터 내용을 설명하자면 다음과 같습니다.

  1. 메인페이지 방문(GET '/')
  2. 서버가 사용자의 비인증 상태를 감지하고 로그인 페이지로 리다이렉트(GET '/users/sign-in')
  3. 로그인 수행후 메인페이지로 리다이렉트 (POST '/users/sign-in' -> GET '/')
  4. 메인 페이지에 게시된 최근 공지사항 글 확인 (GET '/api/v1/notices/latest')
  5. 제일 최근 공지사항 확인 (GET '/notices/6')
  6. 내 프로필 방문(GET '/users/profile')
  7. 메인 페이지로 돌아감


이게 원래는 제가 의도했던 사용자의 일반적인 행동패턴입니다. 그런데, 자세히 살펴보니 이상한 행동패턴들이 발견되었습니다.

 

외국에서 들어온 이상한 요청들
먼저 사례들을 나열해보겠습니다. ip 주소들 위치는 주로 미국과 러시아 였습니다.

 

사례1. 환경변수 및 민감정도 탈취시도

.env..?
credential..?

사례2. 사람이 맞긴한가?...(봇)

robot.txt ..?

사례3. 관리자 정보 탈취

/admin..? 관리자 페이지는 따로 관리하길 잘했네...

사례4. IDOR(https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Direct_Object_Reference_Prevention_Cheat_Sheet.html) 취약점 테스트(를 해주는건지 이걸 이용해서 뭔가를 빼내려고 하는건지는 모르겠다)

로그인도 안하고 공지글 번호를 이것저것 넣고 있었네...

사례5. 다른 도메인 주소랑 헷갈리신걸까..?

요청 uri만 보면 그냥 ip 주소나 도메인 주소를 잘못 입력한거 같은거 같기도 하고??

 

3월 18일 기준으로 아직 외부에 본격적으로 소개하지도 않았는데 이리 많은 분들이 관심이 가져주셔서 감사하려고 했는데, 하는짓들을 보니 실망감을 감출 수 없었습니다.

다행히 Spring web 기반으로 개발하고 Spring Security 설정 덕분에 별 문제는 없었지만, 이러한 불필요한 요청에도 스프링 컨테이너의 모든 라이프 사이클을 거쳐야 한다는 점이 참 거슬렸습니다. 비유하자면, 불청객한테는 단 10원도 쓰기 싫은 상황인거죠.

spring boot의 기본 설정에 의존하면 잘못된 요청은 request -> web App -> Spring App -> response 까지의 긴여정을 거치게 되지만, 그냥 찔러보는 임의의 요청을 처리하는 과정까지 이러한 사이클을 돌리는 것은 리소스가 너무 아까운거 같다..

 

그래서 이상한 요청에 대해선 Filter 제일 앞단에 끊고 그냥 비어있는 화면에 not found만 적어서(마음 같아선 욕을 쓰고 싶었지만..) 서버 리소스 낭비를 최소화 하기로 했습니다.

이렇게 하면, http 요청이 서버에 처음 도착해서 첫 필터링에 걸러지면, 서버는 불필요한 과정을 생략할 수 있겠다.

 

첫 번째 대응: RateLimiter 필터 도입

Rate Limit 관련해서는 bucket4j과 같이 잘 구성된 라이브러리가 있기는 합니다. 그러나 여느 스프링 진영의 라이브러리가 그러하듯 개발의 편의성을 위해 미리 작성된 함수와 기본 설정들이 있고 이러한 부분들을 잘 이해하지 못한 상태에서 적용하게되면, 예상치못한 오류로 시간 낭비를 하게 됩니다.
제가 원했던건 그냥 요청 ip 별로 1분당 60회 요청을 수행하는 것만을 원했기에 이렇게 단순한 기능에 한해서는 직접 Filter를 구현해도 큰 문제가 없을거라 판단했습니다.

@Order(Ordered.HIGHEST_PRECEDENCE)
@Component
class RateLimitingFilter(
    private val endpointRepository: EndpointRepository,
    private val httpLogRepository: HttpLogRepository,
): OncePerRequestFilter() {
    private val requestsMap = ConcurrentHashMap<String, RequestInfo>()
    private val MAX_REQUESTS_PER_MINUTE = 60
    private val TIME_WINDOW = Duration.ofMinutes(1)

    override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) {
        val clientIp = request.remoteAddr
        val now = LocalDateTime.now()

        val requestInfo = requestsMap.getOrPut(clientIp) { RequestInfo(now, 0) }
		
 		// 단일 연산x, 동기화 과정이 필요함
        synchronized(requestInfo) {
            if (Duration.between(requestInfo.startTime, now) > TIME_WINDOW) {
                requestInfo.startTime = now
                requestInfo.requestCount = 0
            }

            requestInfo.requestCount++

            if (requestInfo.requestCount > MAX_REQUESTS_PER_MINUTE) {
                response.status = 429
                response.writer.write("Too many requests. Please try again later.")
                return
            }
        }

        filterChain.doFilter(request, response)
    }
}


사실 이 방법은 현재 문제 상황 해결자체에는 큰 도움이 되진 않았습니다. 그럼에도 불구하고 제일 먼저 이러한 시도를 한 이유는 서비스 이용이 목적이 아닌 어느 이상한 사람이 스크래핑 혹은 DDoS 공격을 할지도 모르겠다는 생각이 들어서였습니다.

물론 라이브러리를 활용하는 것이 정석이지만, 라이브러리를 이해하기위해 투자할 시간이 없고 당장해결해야할 문제도 아니기에 바로적용할 수 있는 방식을 채택하였습니다.

 

두 번째 대응: Endpoint 화이트리스트 도입

스프링은 애플리케이션이 가동되는 순간 컨트롤러 계열 빈을 스캔하면서 접속 가능한 endpoint uri를 뚫어줍니다. 여기서 ApplicationListener를 활용하면 스프링이 컨트롤러 빈을 스캔하는 과정을 추적할 수 있습니다.
저는 이 기능을 활용하여, Endpoint 화이트리스트를 만들고, 그 화이트리스트로 필터링을 하기로 했습니다.

if (!endpointRepository.getEndpoints().contains(request.requestURI) && !endpointRepository.getPatterns().any { request.requestURI.matches(it.toRegex()) }) {
   httpLogRepository.save(
       HttpLog(
           request = HttpRequest(
               method = request.method,
               uri = request.requestURI,
               queryString = request.queryString,
               remoteAddr = request.remoteAddr,
               userAgent = request.getHeader("User-Agent"),
               requestTime = Instant.now().toEpochMilli()
           ),
           response =
                HttpResponse(
                    status = 404,
                    contentType = "text/plain",
                    responseTime = Instant.now().toEpochMilli()
                ),
           userId = null
       )
   )
   response.status = 404
   response.writer.write("not found")
   return
}


위 코드를 필터 앞단에 배치함으로서 만약 누군가가 이상한 주소로 http 통신을 시도하면 그냥 바로 404 상태코드와 함께 비어있는 화면을 보여주고, 서버는 불필요한 리소스 낭비를 하지않게 됩니다.

그런데 이러한 코드는 PathVariable이 있는 URI까지 차단되는 현상까지 발생했습니다. 먼저 문제가 발생했던 코드를 보여 드리겠습니다.

억울한 피해자 발생: PathVariable URI

@Component
class EndpointListener(
    private val endpointRepository: EndpointRepository
) : ApplicationListener<ContextRefreshedEvent> {
    override fun onApplicationEvent(event: ContextRefreshedEvent) {
        val context = event.applicationContext

        context.getBean(RequestMappingHandlerMapping::class.java).handlerMethods.forEach { (key, method) ->
            key.directPaths.forEach {
                endpointRepository.addEndpoint(it)
            }
            endpointRepository.addEndpoint("/images/noimage.jpg")
        }
    }
}

 

위 코드는 'directPaths'만 즉, 고정된 uri만 화이트리스트에 포함시키고 있습니다. 따라서 notices/1 과 같이 pathVariable이 적용된 uri는 해당 화이트리스트에 포함되지 않으며 그로인해 서버에서 관리하는 리소스임에도 불구하고 차단을 해버리는 상황이 생겼던 것이죠.

그래서 추가로 @PathVariable이 선언되어있는 컨트롤러 함수의 변수를 찾고 이를 정규식으로 바꾸는 코드를 추가하였습니다.

@Component
class EndpointListener(
    private val endpointRepository: EndpointRepository
) : ApplicationListener<ContextRefreshedEvent> {
    override fun onApplicationEvent(event: ContextRefreshedEvent) {
        val context = event.applicationContext

        context.getBean(RequestMappingHandlerMapping::class.java).handlerMethods.forEach { (key, method) ->
            key.directPaths.forEach {
                endpointRepository.addEndpoint(it)
            }
            key.patternValues.forEach { pattern ->
                val regexPattern = convertToRegex(pattern, method)
                endpointRepository.addPattern(regexPattern)
            }
            endpointRepository.addEndpoint("/images/noimage.jpg")
        }
    }

    private fun convertToRegex(pattern: String, method: HandlerMethod): String {
        var regexPattern = pattern
        for (methodParameter in method.methodParameters) {
            if (methodParameter.hasParameterAnnotation(PathVariable::class.java)) {
                val regexForType = getRegexForType(methodParameter)
                regexPattern = regexPattern.replace("{${method.method.parameters[methodParameter.parameterIndex].name}}", regexForType)
            }
        }
        return "^$regexPattern$"
    }

    private fun getRegexForType(param: MethodParameter): String {
        return when (param.parameterType) {
            Long::class.java, Int::class.java, Integer::class.java -> "\\d+" // 숫자 타입
            else -> "[\\w-]+" // 문자열 (하이픈 포함)
        }
    }
}

 

이렇게 개선을하니, 초기에 의도했던데로 내가 사용하는 서비스에 한해서만 관리를 수행하고 임의로 찔러보는 요청은 바로 버리는 즉, early return을 통한 리소스 절약과 약간의 보안 강화를 노려볼 수 있었습니다.

교훈 및 결론

기존에 실무에서는 백엔드 개발자로서 API 개발과 비즈니스 로직 구성에만 집중했지만, 실제로 운영을 해보면서 보안과 리소스 관리의 중요성을 다시한번 깨닫게 되었습니다.

단순히 요청을 처리하는 것이 아니라, '이 요청이 정상적인가?'를 고민하는 것이 중요함을 잊지 말아야 겠습니다.