【Scala】【Java】Zip4jのZip解凍時のディレクトリトラバーサル脆弱性をバイトコード操作で直してみた

みなさん、Zip-SlipというZip解凍時のディレクトリトラバーサル脆弱性をご存知でしょうか？

詳細は↓のような他の記事に譲りますが、今回はzip4jというライブラリをバイトコード操作して直してみます。
[Zip Slipディレクトリトラバーサル脆弱性の影響は多くのJavaプロジェクトに](https://www.infoq.com/jp/news/2018/06/zip-slip)

ちなみに、Zipを扱う各ライブラリの脆弱性対策バージョンはここに記載してあります。
[snyk/zip\-slip\-vulnerability: Zip Slip Vulnerability \(Arbitrary file write through archive extraction\)](https://github.com/snyk/zip-slip-vulnerability)
zip4jはjarの配布が無いっぽいですね…。
ソースコード配布するから各自でjarにしてくださいというスタンスだと、最新版を使ってくれない場合が発生しやすくなると思うんだけどなあ…。

↓zip-slipのテストデータ
[zip\-slip\-vulnerability/archives at master · snyk/zip\-slip\-vulnerability](https://github.com/snyk/zip-slip-vulnerability/tree/master/archives)

# 直してみたコード

```scala
import javassist._
import javassist.expr.ExprEditor
import javassist.expr.MethodCall
import net.lingala.zip4j.core.ZipFile

/**
  * バイトコード書き換えによるZip4jの動的なセキュリティパッチ
  */
object Zip4jSecurity {
  def main(args: Array[String]): Unit = {
    patch()

    // バリデーションが効いているかテスト。zip-slipバリデーションによる例外が発生する。
    val zipFile = new ZipFile("/home/momose/Documents/zip-slip.zip")
    zipFile.extractAll("/home/momose/Documents/tmp/")
  }

  /**
    * パッチを当てます
    */
  def patch(): Unit = {
    val cp = ClassPool.getDefault
    val classLoader = Thread.currentThread().getContextClassLoader()
    cp.appendClassPath(new LoaderClassPath(classLoader))
    val cc = cp.get("net.lingala.zip4j.unzip.Unzip")
    val zipSlipValidationEditor = new Zip4jSecurity.ZipSlipValidationEditor
    cc.instrument(zipSlipValidationEditor)
    // クラスローダーに登録する
    cc.toClass(null, clazz.getProtectionDomain)
  }

  private class ZipSlipValidationEditor extends ExprEditor {
    // 1つ目のメソッド呼び出しにのみ適用させるフラグ
    private var unedited = true

    override def edit(m: MethodCall): Unit = {
      if (unedited &&
        m.getClassName.equals("net.lingala.zip4j.model.CentralDirectory") &&
        m.getMethodName.equals("getFileHeaders")) {
        // バリデーションのコード
        val statement ="""
java.io.File outputDir = new java.io.File(outPath);
java.util.List fileHeaders = $0.getFileHeaders($$);
for( int i=0; i<fileHeaders.size(); i++ ){
    net.lingala.zip4j.model.FileHeader e = (net.lingala.zip4j.model.FileHeader)fileHeaders.get(i);
    if (!new java.io.File(outputDir, e.getFileName()).getCanonicalPath().startsWith(outputDir.getCanonicalPath())) {
            throw new RuntimeException("不正なZIPです");
    }
}
$_ = fileHeaders;"""
        m.replace(statement)
        unedited = false
      }
    }
  }

}
```

## 実行結果

```
Exception in thread "main" java.lang.RuntimeException: 不正なZIPです
	at net.lingala.zip4j.unzip.Unzip.extractAll(Unzip.java:50)
	at net.lingala.zip4j.core.ZipFile.extractAll(ZipFile.java:488)
	at net.lingala.zip4j.core.ZipFile.extractAll(ZipFile.java:451)
	at com.github.momosetkn.zip.Zip4jSecurity$.main(Zip4jSecurity.scala:18)
	at com.github.momosetkn.zip.Zip4jSecurity.main(Zip4jSecurity.scala)
```

C言語やLinuxコマンドでいうと`realpath`のようなJavaのメソッド`getCanonicalPath`の結果をバリデーションしています。
↓参考資料
[IDS02\-J\. パス名は検証する前に正規化する](https://www.jpcert.or.jp/java-rules/ids02-j.html)

`net.lingala.zip4j.core.ZipFile#extractAll`メソッドでを使うケースしか、
対策になることを確認していないため、他のケースでは脆弱性が発現するかもしれない。
そのへんは各自でテストしてほしい。

↓この部分の`net.lingala.zip4j.model.CentralDirectory#getFileHeaders()`メソッド呼び出しのところに仕込んでいます。
[zip4j/Unzip\.java at master · supasate/zip4j](https://github.com/supasate/zip4j/blob/master/src/net/lingala/zip4j/unzip/Unzip.java#L51)

# 感想
Scalaだと文字列リテラルが扱いやすくていいですね。
それと、例外が全て非チェック例外のような扱いになるのがいいですね。
javassistで書き換えるとき、バイトコード書き換え対象のJavaのバージョンに合わせたJavaコードで書かないといけないのがげんなり…。