Chall-Manager's scenario decoding process does not check for zip bombs
Description
Chall-Manager is a platform-agnostic system able to start Challenges on Demand of a player. When decoding a scenario (i.e. a zip archive), the size of the decoded content is not checked, potentially leading to zip bombs decompression. Exploitation does not require authentication nor authorization, so anyone can exploit it. It should nonetheless not be exploitable as it is highly recommended to bury Chall-Manager deep within the infrastructure due to its large capabilities, so no users could reach the system. Patch has been implemented by commit 14042aa and shipped in v0.1.4.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/ctfer-io/chall-managerGo | < 0.1.4 | 0.1.4 |
Affected products
1- Range: < 0.1.4
Patches
114042aa66a57impr: handle archive size on unzip to avoid zip bombing (DoS)
3 files changed · +156 −64
cmd/chall-manager-cli/main.go+15 −4 modified@@ -347,22 +347,33 @@ func scenario(dir string) (string, error) { return nil } + // Open the file. + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + // Ensure the header reflects the file's path within the zip archive. fs, err := filepath.Rel(filepath.Dir(dir), path) if err != nil { return err } - f, err := archive.Create(fs) + fst, err := file.Stat() if err != nil { return err } + header, err := zip.FileInfoHeader(fst) + if err != nil { + return err + } + header.Name = fs - // Open the file. - file, err := os.Open(path) + // Create archive + f, err := archive.CreateHeader(header) if err != nil { return err } - defer file.Close() // Copy the file's contents into the archive. _, err = io.Copy(f, file)
pkg/scenario/decompressor.go+132 −0 added@@ -0,0 +1,132 @@ +package scenario + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + errs "github.com/ctfer-io/chall-manager/pkg/errors" + "github.com/pkg/errors" +) + +const ( + blockSize = 1 << 13 // arbitrary +) + +// Decompressor handle the load of +type Decompressor struct { + *Options +} + +type Options struct { + MaxSize int64 + + currSize int64 +} + +// NewDecompressor constructs a fresh Decompressor. +func NewDecompressor(opts *Options) *Decompressor { + if opts == nil { + opts = &Options{} + } + return &Decompressor{ + Options: opts, + } +} + +// Unzip extracts the content of the zip reader into cd. +// It returns the directory it extracted into for Pulumi to use, +// or an error if anything unexpected happens. +func (dec *Decompressor) Unzip(r *zip.Reader, cd string) (string, error) { + outDir := "" + for _, f := range r.File { + if f.FileInfo().IsDir() { + continue + } + filePath, err := sanitizeArchivePath(cd, f.Name) + if err != nil { + return cd, &errs.ErrInternal{Sub: err} + } + + // Save output directory i.e. the directory containing the Pulumi.yaml file, + // the scenario entrypoint. + base := filepath.Base(filePath) + if base == "Pulumi.yaml" || base == "Pulumi.yml" { + if outDir != "" { + return cd, errors.New("archive contain multiple Pulumi yaml/yml file, can't easily determine entrypoint") + } + outDir = filepath.Dir(filePath) + } + + // If the file is in a sub-directory, create it + dir := filepath.Dir(filePath) + if _, err := os.Stat(dir); err != nil { + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return cd, &errs.ErrInternal{Sub: err} + } + } + + // Create and write the file + if err := dec.copyTo(f, filePath); err != nil { + return cd, &errs.ErrInternal{Sub: err} + } + } + + return outDir, nil +} + +func sanitizeArchivePath(d, t string) (v string, err error) { + v = filepath.Join(d, t) + if strings.HasPrefix(v, filepath.Clean(d)) { + return v, nil + } + return "", &ErrPathTainted{ + Path: t, + } +} + +func (dec *Decompressor) copyTo(f *zip.File, filePath string) error { + outFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE, f.Mode()) + if err != nil { + return err + } + defer outFile.Close() + + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + + for { + n, err := io.CopyN(outFile, rc, blockSize) + if err != nil { + if err == io.EOF { + return nil + } + return err + } + dec.currSize += n + + if dec.currSize > dec.MaxSize { + return ErrTooLargeContent + } + } +} + +type ErrPathTainted struct { + Path string +} + +func (err ErrPathTainted) Error() string { + return fmt.Sprintf("filepath is tainted: %s", err.Path) +} + +var _ error = (*ErrPathTainted)(nil) + +var ( + ErrTooLargeContent = errors.New("too large archive content") +)
pkg/scenario/io.go+9 −60 modified@@ -6,17 +6,16 @@ import ( "context" "encoding/base64" "fmt" - "io" "os" "path/filepath" - "strings" errs "github.com/ctfer-io/chall-manager/pkg/errors" "github.com/pkg/errors" ) const ( scenarioDir = "scenario" + scenSize = 1 << 30 // 1Gb ) // Decode (base 64) and unzip the scenario content into the scenario directory @@ -31,7 +30,6 @@ func Decode(ctx context.Context, challDir, scenario string) (string, error) { randDir := randName() cd := filepath.Join(challDir, scenarioDir, randDir) - outDir := "" if _, err := os.Stat(cd); err == nil { return cd, &errs.ErrInternal{Sub: fmt.Errorf("scenario directory %s already exist", cd)} } @@ -50,65 +48,16 @@ func Decode(ctx context.Context, challDir, scenario string) (string, error) { if err != nil { return cd, errors.Wrap(err, "base64 decoded invalid zip archive") } - for _, f := range r.File { - if f.FileInfo().IsDir() { - continue - } - filePath, err := sanitizeArchivePath(cd, f.Name) - if err != nil { - return cd, &errs.ErrInternal{Sub: err} - } - // Save output directory i.e. the directory containing the Pulumi.yaml file, - // the scenario entrypoint. - base := filepath.Base(filePath) - if base == "Pulumi.yaml" || base == "Pulumi.yml" { - if outDir != "" { - return cd, errors.New("archive contain multiple Pulumi yaml/yml file, can't easily determine entrypoint") - } - outDir = filepath.Dir(filePath) - } - - // If the file is in a sub-directory, create it - dir := filepath.Dir(filePath) - if _, err := os.Stat(dir); err != nil { - if err := os.MkdirAll(dir, os.ModePerm); err != nil { - return cd, &errs.ErrInternal{Sub: err} - } - } - - // Create and write the file - if err := copyTo(f, filePath); err != nil { - return cd, &errs.ErrInternal{Sub: err} - } - } - - return outDir, Validate(ctx, outDir) -} - -func sanitizeArchivePath(d, t string) (v string, err error) { - v = filepath.Join(d, t) - if strings.HasPrefix(v, filepath.Clean(d)) { - return v, nil - } - return "", fmt.Errorf("filepath is tainted: %s", t) -} - -func copyTo(f *zip.File, filePath string) error { - outFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE, 0777) + // Safely decompress the archive + dec := NewDecompressor(&Options{ + MaxSize: scenSize, + }) + outDir, err := dec.Unzip(r, cd) if err != nil { - return err + return cd, err } - defer outFile.Close() - rc, err := f.Open() - if err != nil { - return err - } - defer rc.Close() - - if _, err := io.Copy(outFile, rc); err != nil { - return err - } - return nil + // Validate its content + return outDir, Validate(ctx, outDir) }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-r7fm-3pqm-ww5wghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-53633ghsaADVISORY
- github.com/ctfer-io/chall-manager/commit/14042aa66a577caee777e10fe09adcf2587d20ddghsax_refsource_MISCWEB
- github.com/ctfer-io/chall-manager/releases/tag/v0.1.4ghsax_refsource_MISCWEB
- github.com/ctfer-io/chall-manager/security/advisories/GHSA-r7fm-3pqm-ww5wghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.