bytebuddy로 Java class 일부 method 교체하기 (해킹)

2023. 1. 24. 23:13Development

java.net.URI$match

Uri 에 특수문자가 들어가게되면 http protocal을 오갈때 보통 encoding을 통해서 parsing에 문제가 없도록 해주게 됩니다. 하지만 이게 언제부터 생긴 국룰인지 모르겠지만 과거에는 그렇지 않았음을 알게 되었습니다.

 

Legacy client들이 encoding을 하지 않고, 심지어 해킹이 가능할 수 있는 특수문자 '파이프'와 같은 char를 날 것으로 보내오고 있었습니다.

 

현재 Spring cloud gateway(이하 SCG)를 apigateway로 준비하고 있는 중입니다. 그 과정에서 TC를 통해 해당 이슈가 드러나게 되었는 데, 내용인 즉슨 Netty에서 URI를 검수하면서 특정 특수 문자에 대해서는 그냥 400 Bad gateway를 내려버리는 것 입니다.

java.net.URI$match

private static boolean match(char c, long lowMask, long highMask) {
    if (c == 0) // 0 doesn't have a slot in the mask. So, it never matches.
        return false;
    if (c < 64)
        return ((1L << c) & lowMask) != 0;
    if (c < 128)
        return ((1L << (c - 64)) & highMask) != 0;
    return false;
}

명확하네요… 깔끔하게 static함수이고 외부 의존성도 없습니다. 어떻게 밖에서 예외처리로 끼어들 틈조차 없는 단순한 method 입니다.

 

실제로 https://github.com/reactor/reactor-netty/issues/1561 동일한 문제를 접하고 있는 사람들도 있었습니다.

(여기서 언급되는 workaround로 제시한 이슈 해결법도 추후에 정리해놔야겠습니다 ㅠㅠ)

 

반면 zuul의 Tomcat 에서는 동일한 상황에서 아래와 같이 개입이 가능하다고 합니다.

    @Bean fun webServerFactory(): ConfigurableServletWebServerFactory {
        val factory = TomcatServletWebServerFactory()
        factory.addConnectorCustomizers(object : TomcatConnectorCustomizer {
            override fun customize(connector: Connector) {
                connector.setProperty("relaxedQueryChars", "|{}[]")
            }
        })
        return factory
    }

이미 SCG의 도입을 위해 여기까지 달려온 마당에 ‘|’ 하나 때문에 그르칠 수는 없는 노릇이고, 해당 패치를 기다리자니 수정사항이 만만치 않아보였습니다.


SCG 의 경우 URI class를 많이 사용하는 데, URI instance 생성과정에서 항상 걸림돌이 될 것이기에 이를 대체하는 다른 class가 나와야한다는 의미가 될 것 입니다.

 

심지어 java.net.URI class는 Netty의 구현부도 아니며 final class 입니다! 그러니 간단히 해결될 것으로 보이지 않았습니다.

이미 완성된 특정 method의 로직을 일시적으로 바꾸고 싶다, 이는 많이 경험해봤던 요구사항입니다.

 

바로 TC를 만들때 mock 서버와 같은…

이 기술이 java 에서 제공되고 있었기에 mockito 같은 형태가 나올 수 있었다고 확신이 들었기에 그 부분을 그런 관점에서 해결하기 위해 바라보게 되었습니다.

 

Instrument

javaagent를 이용한 instrument 가 바로 그 역할을 해주는 것이었습니다.

    public static void premain(String args, Instrumentation inst) {
        instrumentation = inst;
    }
jar {
    manifest {
        attributes(
                "Premain-Class": “com.”myclassname,
                "Can-Redefine-Classes": true
        )
    }
}

Java에서 premain method를 구현한 class를 jar 빌드시에 저렇게 선언해주게 되면
META-INF/MANIFEST.MF 에 해당 정보가 들어가게 되며, 이 jar는 java로 구동시 아래와 같이 jvm parameter로 넘겨서 agent로 동작시킬 수가 있습니다.

-javaagent:/…/javaagent-0.0.1.jar

premain에서 각 class가 로딩될때 intercept를 하는 등의 jvm의 동작 가운데 원하는 로직을 Common하게 넣을 수도 있고, class의 transform이 가능하게 해주었습니다.

사실 이를 이용해서 목적을 달성하는 건 쉽지 않았습니다. class의 method를 transform하는 게 sample code 없이는 쉽사리 이해가 가지 않았기 때문입니다.

 

ByteBuddy

    implementation 'net.bytebuddy:byte-buddy:1.12.10'
    implementation 'net.bytebuddy:byte-buddy-agent:1.12.10'

그러다가 찾게된게 바로 ByteBuddy 입니다. 이는 instrument 를 이용한 여러 동작을 수월하게 해줄 수 있는 wrapper의 느낌을 주었습니다.

별도의 javaagent용 jar를 만들어서 premain으로 구동시키지 않아도

Instrumentation instrumentation = ByteBuddyAgent.install();

이렇게 instruemtation을 얻을 수 있게 해줍니다. 이를 가지고 java.net.URI$match static method 만 원하는 char를 validation 체크에서 빼도록 logic을 수정해 시도해보았습니다.

public class URIMatchCustomMethod {

    public static boolean match(char c, long lowMask, long highMask) {
        if ('|' == c || '^' == c) {
            return true;
        }
        // 0 doesn't have a slot in the mask. So, it never matches.
        if (c == 0) {
            return false;
        }
        if (c < 64) {
            return ((1L << c) & lowMask) != 0;
        }
        if (c < 128) {
            return ((1L << (c - 64)) & highMask) != 0;
        }
        return false;
    }
}

간단히 위에 3줄을 더 추가해주었습니다.

그리고 이제 이를 Memory에 있는 오리지널 bytecode와 바꿔볼 차례입니다.

Instrumentation instrumentation = ByteBuddyAgent.install();
new ByteBuddy()
    .redefine(URI.class)
    .method(named("match"))
    .intercept(MethodDelegation.to(URIMatchCustomMethod.class))
    .make()
    .load(
        URIMatchCustomMethod.class.getClassLoader(),
        ClassReloadingStrategy.fromInstalledAgent());

File temp = Files.createTempDirectory("inject_byte_code").toFile();
ClassInjector.UsingInstrumentation.of(temp,
    ClassInjector.UsingInstrumentation.Target.BOOTSTRAP, instrumentation).inject(
    Collections.singletonMap(
        new TypeDescription.ForLoadedType(URIMatchCustomMethod.class),
        ClassFileLocator.ForClassLoader.read(URIMatchCustomMethod.class)));

ByteBuddy instance를 만들고 redefine하고자 하는 class를 URI.class로 설정합니다. 그리고 target이 되는 method를 이름을 match로 설정하고 교체할 class정보를 넘겨줍니다.
내부에서 동일한 이름과 Parameter 타입을 가진 method가 accessible하면 해당 method로 치환을 해주게 되는 것 같습니다.

그리고 마지막으로 ClassInjector를 이용해서 해당 class를 Inject해줍니다.
이 부분이 없으면 아래와 같은 에러가 발생합니다.

java.lang.NoClassDefFoundError: com/…/URIMatchCustomMethod
    at java.net.URI.match(URI.java) ~[?:?]
    at java.net.URI$Parser.scan(URI.java:3059) ~[?:?]
    at java.net.URI$Parser.checkChars(URI.java:3082) ~[?:?]
    at java.net.URI$Parser.checkChar(URI.java:3094) ~[?:?]
    at java.net.URI$Parser.parse(URI.java:3109) ~[?:?]
    at java.net.URI.<init>(URI.java:600) ~[?:?]
    at java.net.URI.create(URI.java:881) ~[?:?]

 

Runtime에 해당 class를 못찾는…
임의로 URIMatchCustomMethod class를 접근도 해보았지만 같은 현상이 었고, ClassInjector.UsingInstrumentation.of 를 이용한 코드가 추가되고는 문제가 해결되었습니다.

 

의문이 많이 남는 과정이긴 했지만, 원하는 동작을 수행할 수 있게 되었습니다.

쌩뚱맞은 접근으로 시도한 것이지만 결론적으로 얻은 게 많은 과정이었습니다.

 

이 기술을 응용해서 다양한 시도를 해볼 기회가 있을면 재미있을 것 같다는 생각도 들었습니다.