読者です 読者をやめる 読者になる 読者になる

この日記は私的なものであり所属会社の見解とは無関係です。 GitHub: takahashikzn

Tomcat-8.0.32上でRhinoの初期化に失敗する問題を強引に解消する

Tomcat-8.0.32がリリースされています。

いつも通り、早速アップデートして試してみたのですがそもそもアプリが起動しなくなりました。 こんなエラーを吐きます。

Caused by: java.lang.StringIndexOutOfBoundsException: String index out of range: 3
    at java.lang.String.charAt(String.java:658)
    at org.apache.catalina.loader.WebappClassLoaderBase.filter(WebappClassLoaderBase.java:2780)
    at org.apache.catalina.loader.WebappClassLoaderBase.loadClass(WebappClassLoaderBase.java:1253)
    at org.apache.catalina.loader.WebappClassLoaderBase.loadClass(WebappClassLoaderBase.java:1142)
    at org.mozilla.javascript.Kit.classOrNull(Kit.java:60)
    at org.mozilla.javascript.NativeJavaPackage.getPkgProperty(NativeJavaPackage.java:129)
    at org.mozilla.javascript.NativeJavaPackage.get(NativeJavaPackage.java:84)
    at org.mozilla.javascript.NativeJavaTopPackage.init(NativeJavaTopPackage.java:96)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at org.mozilla.javascript.ScriptableObject.buildClassCtor(ScriptableObject.java:1309)
    at org.mozilla.javascript.LazilyLoadedCtor.buildValue0(LazilyLoadedCtor.java:105)
    at org.mozilla.javascript.LazilyLoadedCtor.access$000(LazilyLoadedCtor.java:18)
    at org.mozilla.javascript.LazilyLoadedCtor$1.run(LazilyLoadedCtor.java:90)
    at java.security.AccessController.doPrivileged(Native Method)
    at org.mozilla.javascript.LazilyLoadedCtor.buildValue(LazilyLoadedCtor.java:86)
    at org.mozilla.javascript.LazilyLoadedCtor.init(LazilyLoadedCtor.java:66)
    at org.mozilla.javascript.ScriptableObject.sealObject(ScriptableObject.java:2221)

何事?と思ってorg.apache.catalina.loader.WebappClassLoaderBase.filterのソースを眺めてみるとこんな感じ。

    protected boolean filter(String name, boolean isClassName) {

        if (name == null)
            return false;

        char ch;
        if (name.startsWith("javax")) {
            /* 5 == length("javax") */
            ch = name.charAt(5);
            if (isClassName && ch == '.') {
                /* 6 == length("javax.") */
                if (name.startsWith("servlet.jsp.jstl.", 6)) {
                    return false;
                }
                if (name.startsWith("el.", 6) ||
                    name.startsWith("servlet.", 6) ||
                    name.startsWith("websocket.", 6)) {
                    return true;
                }
            } else if (!isClassName && ch == '/') {
                /* 6 == length("javax/") */
                if (name.startsWith("servlet/jsp/jstl/", 6)) {
                    return false;
                }
                if (name.startsWith("el/", 6) ||
                    name.startsWith("servlet/", 6) ||
                    name.startsWith("websocket/", 6)) {
                    return true;
                }
            }
        } else if (name.startsWith("org")) {
            /* 3 == length("org") */
            ch = name.charAt(3); // ← 2780行目はココ!!
            if (isClassName && ch == '.') {
                /* 4 == length("org.") */
                if (name.startsWith("apache.", 4)) {
                    /* 11 == length("org.apache.") */
                    if (name.startsWith("tomcat.jdbc.", 11)) {
                        return false;
                    }
                    if (name.startsWith("el.", 11) ||
                        name.startsWith("catalina.", 11) ||
                        name.startsWith("jasper.", 11) ||
                        name.startsWith("juli.", 11) ||
                        name.startsWith("tomcat.", 11) ||
                        name.startsWith("naming.", 11) ||
                        name.startsWith("coyote.", 11)) {
                        return true;
                    }
                }
            } else if (!isClassName && ch == '/') {
                /* 4 == length("org/") */
                if (name.startsWith("apache/", 4)) {
                    /* 11 == length("org/apache/") */
                    if (name.startsWith("tomcat/jdbc/", 11)) {
                        return false;
                    }
                    if (name.startsWith("el/", 11) ||
                        name.startsWith("catalina/", 11) ||
                        name.startsWith("jasper/", 11) ||
                        name.startsWith("juli/", 11) ||
                        name.startsWith("tomcat/", 11) ||
                        name.startsWith("naming/", 11) ||
                        name.startsWith("coyote/", 11)) {
                        return true;
                    }
                }
            }
        }
        return false;
    }

どうやら、nameに"org"という文字列が渡されているようです。何だこのクラス名?

Rhinoの初期化ロジックを調べる

Rhinoに問題があるのだろうと思ってソースを見てみたところ、NativeJavaPackage#getPkgPropertyはこんな感じです。

    synchronized Object getPkgProperty(String name, Scriptable start,
                                       boolean createPkg)
    {

        // ...(略) 

        if (shutter == null || shutter.visibleToScripts(className)) {
            Class<?> cl = null;
            if (classLoader != null) {
                cl = Kit.classOrNull(classLoader, className); // ←129行目はココ!!
            } else {
                cl = Kit.classOrNull(className);
            }
            if (cl != null) {
                WrapFactory wrapFactory = cx.getWrapFactory();
                newValue = wrapFactory.wrapJavaClass(cx, getTopLevelScope(this), cl);
                newValue.setPrototype(getPrototype());
            }
        }

        // ...(略) 
    }

で、getPkgPropertyの呼び出し元であるNativeJavaTopPackageはこんな感じです。

public class NativeJavaTopPackage
    extends NativeJavaPackage implements Function, IdFunctionCall
{
    // we know these are packages so we can skip the class check
    // note that this is ok even if the package isn't present.
    private static final String[][] commonPackages = {
            {"java", "lang", "reflect"},
            {"java", "io"},
            {"java", "math"},
            {"java", "net"},
            {"java", "util", "zip"},
            {"java", "text", "resources"},
            {"java", "applet"},
            {"javax", "swing"}
    };

    // ...(略)

    public static void init(Context cx, Scriptable scope, boolean sealed)
    {
        // ...(略)

        // We want to get a real alias, and not a distinct JavaPackage
        // with the same packageName, so that we share classes and top
        // that are underneath.
        String[] topNames = ScriptRuntime.getTopPackageNames();
        NativeJavaPackage[] topPackages = new NativeJavaPackage[topNames.length];
        for (int i=0; i < topNames.length; i++) {
            topPackages[i] = (NativeJavaPackage)top.get(topNames[i], top); // ← 96行目はココ!!
        }

        // ...(略)
    }

    // ...(略)
}

そして、ScriptRuntime#getTopPackageNames()はこれ。

    static String[] getTopPackageNames() {
        // Include "android" top package if running on Android
        return "Dalvik".equals(System.getProperty("java.vm.name")) ?
            new String[] { "java", "javax", "org", "com", "edu", "net", "android" } :
            new String[] { "java", "javax", "org", "com", "edu", "net" };
    }

つまり、NativeJavaPackage#getPkgPropertyの使い方に問題がありそうです。

nameからパッケージ名を導出したいわけですが、

  • 引数nameがFQCNならクラスロードが成功するのでその所属パッケージを取得
  • クラスロードが失敗するならnameはパッケージ名なのでnameをそのまま使う

という手抜きな実装になっています。

Kit#classOrNullはその名前の通り、クラスロード失敗時にnullを返すことが期待されています。 しかしTomcat-8.0.32のクラスローダの仕様が変わったためにIllegalArgumentExceptionではなくStringIndexOutOfBoundsExceptionが発生するようになり、catchで握りつぶせなくなったわけです。

    public static Class<?> classOrNull(ClassLoader loader, String className)
    {
        try {
            return loader.loadClass(className);
        } catch (ClassNotFoundException ex) {
        } catch (SecurityException ex) {
        } catch (LinkageError ex) {
        } catch (IllegalArgumentException e) { // ←クラスロード失敗時にはIllegalArgumentExceptionのみが発生する想定
            // Can be thrown if name has characters that a class name
            // can not contain
        }
        return null;
    }

パッチを当てる

対応方法は色々あると思いますが、僕はお馴染みのJavassistでパッチを当てることにしました。 catch節を追加し、StringIndexOutOfBoundsExceptionを無視するようにします。

final ClassPool classPool = ClassPool.getDefault();
final CtClass kitCls = classPool.get(Kit.class.getName());
final CtClass classLoaderType = classPool.get(ClassLoader.class.getName());
final CtClass stringType = classPool.get(String.class.getName());
final CtClass indexOutOfBoundsType = classPool.get(StringIndexOutOfBoundsException.class.getName());

ctClass //
    .getDeclaredMethod("classOrNull", array(classLoaderType, stringType)) //
    .addCatch("{ return null; }", indexOutOfBoundsType);

ctClass //
    .getDeclaredMethod("classOrNull", array(stringType)) //
    .addCatch("{ return null; }", indexOutOfBoundsType);

// これをクラスパス上のどこかに書き込む
final byte[] patchedClass = ctClass.toBytecode();

これで元のエラーは発生せず、正常に起動するようになりました。めでたしめでたし。