배포한 사이트에 사용자가 유입되고 나서부터 리소스에 대한 봇 공격들이 탐지 되었습니다.
보안상으로는 문제가 없지만, 모니터링 환경에서 아래 사진과 같이 정상적인 API 요청과 봇 공격들이 섞여서 APM 사용이 불편해졌습니다.
이를 개선하려 앞단 웹서버(nginx)에서 봇공격으로 의심되는 API 경로들에 대하서는 WAS에 요청 자체를 가지 않도록 조치했지만, 계속 새로운 경로의 공격들이 감지되었고 이에 대해 어떻게 처리해야할지 고민하였습니다.
- 사용하는 APM은 네이버에서 만든 Pinpoint입니다.

Sevlet에서 API와 매핑되지 못 한 요청들에 대하여 (404에러)
agent가 Collector에게 Push하지 않도록 하면 될 거라고 생각했습니다.
다만... 내가 사용하고 있는 Pinpoint 2.2.3 버전에서는 그런 기능이 없었고(3.~버전부터는 API별로 요청을 확인할 수 있다고 하는데 가능할 수도 있겠다)
직접 커스텀해야한다고 생각했습니다.
Pinpoint에서 Method Trace 내역을 보면 다음과 같습니다

관측의 첫 시작점과 끝이 Servlet 작업으로 보입니다.
Pinpoint는 Java 기반의 Apm이다. 그렇다면 Servlet 프로세스 자체가 필요없을 수도 있는 Java 어플리케이션에서도 작동할 것입니다. 그런 어플리케이션에선 아마 첫 시작점이 Servlet Process가 아닐 수도 있겠습니다.
다만 하나 확실한 건 Dispatcher Selvlet 이후에 관측이 시작되는 것은 아닐 것입니다.
Dispatcher Servlet이 Request를 받은 시점부터 지표 수집을 하고
Response를 받은 시점 이후에 Apm Collector에게 지표를 보낸다고 가정하자.
그렇다면 그게 어떻게 가능할까?
Request Response 이전과 이후에 특정 작업을 추가한다는 것을 생각해보면 프록시 패턴처럼 느껴지기도 한다.
스프링에선 프록시를 구현하는데 CGLIB를 사용한다.
그렇다면 혹시 Pinpoint에서도 CGLIB를 사용하는게 아닐까?
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.27.0-GA</version>
</dependency>
<dependency>
<groupId>cglib</groupId>
<artifactId>cglib-nodep</artifactId>
<version>3.2.12</version>
</dependency>
그리고 CGLIB는 바이트 코드 조작을 통해 프록시를 생성하다.
실제로 Pinpoint 소스를 보면 CGLIB, Javaassist등의 BCI(Byte Code Insertion) 관련 라이브러리를 사용하는 것을 알 수 있다. 바이트 코드 조작을 통해 지표를 수집하고 전송하는 것으로 판단된다.
그런데..CGLIB는 원본 class의 바이트코드를 수정하는게 아니라 원본 class의 정보를 가지고와서 바이트코드를 조작하여 프록시 구현체를 만드는 것으로 알고 있다.
그리고 원본 대신 프록시 구현체를 사용하기위해 Spring이 주입해준다.
다만 Dispatcher Servlet은 Spring ApplicationContext에서 관리하는 Bean이 아니라고 알고있다.
(Controller, Handler Mapper등 Bean에 의존하긴 한다) 등록할 순 있지만 일단 Bean이 아니라고 가정해보자
그렇다면 바이트코드가 조작된 Dispatch Servlet은
주입을 누가? 언제? 어떻게 하는 것일까
Java 어플리케이션에 Pinpoint를 적용하려면
-javaagent:/usr/local/pinpoint-bootstrap-2.2.3-NCP-RC1.jar
해당 옵션을 VM Option에 추가해야한다.
그리고 jvm은 main 메소드 시작전
java agent의 premain 메소드를 호출한다.

Spring이 Bean들에 대해서 프록시 구현체를 주입한다고 하면
Java agent는 Dispatch Servlet가 JVM에 로드될 때 Instrumentation Api를 통해 바이트코드를 조작 후 JVM 메모리에 주입? 한다고 볼 수 있을 거같다.
Premain-Class: com.navercorp.pinpoint.bootstrap.PinpointBootStrap
public class PinpointBootStrap {
private static final BootLogger logger = BootLogger.getLogger(PinpointBootStrap.class);
private static final LoadState STATE = new LoadState();
public static void premain(String agentArgs, Instrumentation instrumentation)
ClassFileTransFormer를 추가하는 부분
ClassFileTransformer classFileTransformer = wrap(this.classFileTransformer);
final JvmVersion version = JvmUtils.getVersion();
if (version.onOrAfter(JvmVersion.JAVA_9)) {
final JavaModuleFactory javaModuleFactory = JavaModuleFactoryFinder.lookup(instrumentation);
ClassFileTransformModuleAdaptor classFileTransformModuleAdaptor = new ClassFileTransformerModuleHandler(instrumentation, classFileTransformer, javaModuleFactory);
classFileTransformer = wrapJava9ClassFileTransformer(classFileTransformModuleAdaptor);
lambdaFactorySetup(instrumentation, classFileTransformModuleAdaptor, javaModuleFactory);
instrumentation.addTransformer(classFileTransformer, true);
} else {
instrumentation.addTransformer(classFileTransformer, true);
}
APM은 스프링 빈 또한 감지한다!

어찌 됐든 APM은 원본이 아니라 프록시화 된 빈을 탐지할 수 있다는 것을 알 수 있다.
그렇지만 CGLIB를 통해 구현된 프록시 구현체는 애초에 원본 파일이 없다. 바이트코드만 존재할 뿐이다.
원본 클래스 파일이 없기 때문에, 스프링 빈 프록시에 APM 관련 코드가 삽입되는 시점은 원본 클래스의 클래스 로딩 시점과 관련이 없을 거라고 생각한다.
아마도
0) JVM이 빈의 원본 클래스를 바이트 코드로 생성하고 JVM에 올림
1) 스프링이 CGLIB로 프록시 구현체의 바이트 코드를 생성하고 JVM에 올림
2) 프록시 구현체의 바이트코드가 Jvm에 로드 된 후에... javaAgent가 redefineClasses 메소드를 통해 한 번 더 바이트 코드를 수정.
하지 않을까 싶다.
final Method getWrappedInstanceMethod = getGetWrappedInstanceMethod(result);
if (getWrappedInstanceMethod != null) {
final Object bean = getWrappedInstanceMethod.invoke(result);
processBean(beanName, bean);
}
protected final void processBean(String beanName, Object bean) {
if (beanName == null || bean == null) {
return;
}
Class<?> clazz = bean.getClass();
if (clazz == null) {
return;
}
if (!filter.isTarget(SpringBeansTargetScope.POST_PROCESSOR, beanName, clazz)) {
return;
}
// If you want to trace inherited methods, you have to retranform super classes, too.
instrumentor.retransform(clazz, transformer);
filter.addTransformed(clazz.getName());
if (logger.isInfoEnabled()) {
logger.info("Retransform {}", clazz.getName());
}
}
그리고 위의 코드를 보면 빈을 가져오고 프록시 구현체라면 getWrappedInstance로 원본 빈을 가져와 직접 조작을 하는 듯 싶다.
즉 Dispatch Servlet이 빈으로 등록된다면
Dispatch Servlet 원본 클래스의 바이트코드가 수정되어서 JVM 메모리에 올라갈 것으로 보인다.
(Spring Boot부턴 기본적으로 빈으로 등록된다고 한다)
원본 바이트코드를 수정하던, 새로운 바이트코드로 프록시 구현체를 만들던
어떤 방식으로든 APM 기능이 추가된 바이트코드를 만들고
이를 Instrumentation Api를 통하여 유연하게 Jvm 메모리에 올릴 수 있다.
이 과정에서 서로 다른 BCI가 겹치게 된다면 위험할 수 있는데 이를 BCI를 사용하는 툴을 만드는 회사에서 신경써야할 부분이라고 생각한다.
Java agent로 손쉽게 Apm을 적용할 수 있지만 커스텀하기엔..
코드의 양이 너무 방대하고 무엇보다 바이트 코드를 조작하는 코드를 이해하는 것 너무 힘들다고 느꼈다.
앞단 웹서버에 수동으로 거부하는 URL을 추가하거나
해당 기능이 있는 다른 APM 등을 찾아서 선택하는게 좋을듯 싶다..
헷갈렸던 개념들
- ASM: 바이트 코드를 직접 조작할 수 있게 도와주는 라이브러리다.
- Instrumentation API: 바이트 코드를 조작하기보단, 클래스 로딩 또는 이후에 바이트 코드에 개발자가 접근할 수 있고 재정의하도록 해주는 API에 가깝다.
- CGLIB: 바이트 코드를 조작하는 것이 중점이기보단, ASM을 사용하여 바이트 조작하며
이를 통해 새로운 프록시를 만드는데 중점이 맞춰져 있다.(Like. Jdk Dynamic Proxy)
즉 Instrumentation API + ASM을 조합하여 런타임에 바이트 코드를 다룰 수 있다고 생각하면 될 거같다.