VYPR
Medium severity6.5GHSA Advisory· Published Jun 12, 2026· Updated Jun 12, 2026

Fleet: Observer-level enrollment secret extraction via ORDER BY oracle on Apple MDM commands endpoint

CVE-2026-46371

Description

Fleet's Apple MDM commands endpoint allowed Observer users to extract host secrets and APNS tokens via an ORDER BY oracle.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Fleet's Apple MDM commands endpoint allowed Observer users to extract host secrets and APNS tokens via an ORDER BY oracle.

Vulnerability

The vulnerability is in the GET /api/v1/fleet/mdm/apple/commands endpoint. It uses a deprecated helper that does not validate the order_key parameter against a column allowlist. The query joins the hosts and nano_enrollments tables, so any column from these tables can be supplied as order_key. This allows an authenticated Observer user to perform a cursor-based binary search using the after pagination parameter to extract sensitive values such as node_key, orbit_node_key, and APNS tokens. Affected versions are not explicitly listed, but the vulnerability exists in Fleet deployments with Apple MDM enabled and at least one queued MDM command. [1][2]

Exploitation

An attacker needs authenticated Observer access and a Fleet deployment with Apple MDM enabled and at least one queued MDM command. The attacker supplies a target column as order_key and uses the after cursor to binary-search the column value character by character. The response's presence or absence of results reveals each character. The targeted values never appear in the response body. [1][2]

Impact

Successful extraction of node_key or orbit_node_key allows the attacker to impersonate enrolled hosts to Fleet's osquery and Orbit endpoints, submit fabricated host data, and retrieve pending scripts and commands. Extracted APNS tokens are exploitable only if the attacker also possesses the organization's APNS certificate. The vulnerability leads to information disclosure and potential host impersonation. [1][2]

Mitigation

A patch is available; administrators should upgrade to the latest Fleet version. If immediate upgrade is not possible, restrict the Observer role to fully trusted users and rotate node_key and orbit_node_key for any potentially exposed hosts by re-enrolling them. The exact fixed version is not specified in the provided references. [1][2]

AI Insight generated on Jun 12, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

1
34cf95d77785

Improve filtering on commands endpoints (#44426)

https://github.com/fleetdm/fleetJordan MontgomeryApr 29, 2026Fixed in 4.84.2via llm-release-walk
4 files changed · +189 3
  • changes/fix-mdm-commands-filtering+1 0 added
    @@ -0,0 +1 @@
    +* Improved validation for invalid `order_key` values in `/api/v1/fleet/commands`, `/api/v1/fleet/mdm/commands` and `/api/v1/fleet/mdm/apple/commands` endpoints.
    
  • server/datastore/mysql/apple_mdm.go+14 1 modified
    @@ -1117,6 +1117,16 @@ WHERE
     	return results, nil
     }
     
    +var mdmAppleCommandsAllowedOrderKeys = common_mysql.OrderKeyAllowlist{
    +	"command_uuid": "nvq.command_uuid",
    +	"request_type": "nvq.request_type",
    +	"status":       "COALESCE(NULLIF(nvq.status, ''), 'Pending')",
    +	"updated_at":   "COALESCE(nvq.result_updated_at, nvq.created_at)",
    +	"hostname":     "h.hostname",
    +	"device_id":    "ne.device_id",
    +	"name":         "nvq.name",
    +}
    +
     func (ds *Datastore) ListMDMAppleCommands(
     	ctx context.Context,
     	tmFilter fleet.TeamFilter,
    @@ -1148,7 +1158,10 @@ WHERE
        nvq.active = 1 AND
         %s
     `, ds.whereFilterHostsByTeams(tmFilter, "h"))
    -	stmt, params := appendListOptionsWithCursorToSQL(stmt, nil, &listOpts.ListOptions)
    +	stmt, params, err := appendListOptionsWithCursorToSQLSecure(stmt, nil, &listOpts.ListOptions, mdmAppleCommandsAllowedOrderKeys)
    +	if err != nil {
    +		return nil, ctxerr.Wrap(ctx, err, "list commands")
    +	}
     
     	var results []*fleet.MDMAppleCommand
     	if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, stmt, params...); err != nil {
    
  • server/datastore/mysql/mdm.go+19 2 modified
    @@ -14,10 +14,21 @@ import (
     	"github.com/fleetdm/fleet/v4/server/mdm"
     	"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
     	microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft"
    +	common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
     	"github.com/google/go-cmp/cmp"
     	"github.com/jmoiron/sqlx"
     )
     
    +var mdmCommandsAllowedOrderKeys = common_mysql.OrderKeyAllowlist{
    +	"command_uuid": "command_uuid",
    +	"request_type": "request_type",
    +	"status":       "status",
    +	"updated_at":   "updated_at",
    +	"hostname":     "hostname",
    +	"host_uuid":    "host_uuid",
    +	"name":         "name",
    +}
    +
     func (ds *Datastore) GetMDMCommandPlatform(ctx context.Context, commandUUID string) (string, error) {
     	stmt := `
     SELECT CASE
    @@ -103,7 +114,10 @@ func (ds *Datastore) ListMDMCommands(
     	jointStmt, params := getCombinedMDMCommandsQuery(ds, listOpts.Filters.HostIdentifier)
     	jointStmt += ds.whereFilterHostsByTeams(tmFilter, "combined_commands")
     	jointStmt, params = addRequestTypeFilter(jointStmt, &listOpts.Filters, params)
    -	jointStmt, params = appendListOptionsWithCursorToSQL(jointStmt, params, &listOpts.ListOptions)
    +	jointStmt, params, err := appendListOptionsWithCursorToSQLSecure(jointStmt, params, &listOpts.ListOptions, mdmCommandsAllowedOrderKeys)
    +	if err != nil {
    +		return nil, nil, nil, ctxerr.Wrap(ctx, err, "list commands")
    +	}
     	var results []*fleet.MDMCommand
     	if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, jointStmt, params...); err != nil {
     		return nil, nil, nil, ctxerr.Wrap(ctx, err, "list commands")
    @@ -357,7 +371,10 @@ WHERE
     	if listOpts.PerPage == 0 {
     		listOpts.PerPage = 10
     	}
    -	listStmt, params = appendListOptionsWithCursorToSQL(listStmt, params, &listOpts.ListOptions)
    +	listStmt, params, err = appendListOptionsWithCursorToSQLSecure(listStmt, params, &listOpts.ListOptions, mdmCommandsAllowedOrderKeys)
    +	if err != nil {
    +		return nil, nil, nil, ctxerr.Wrap(ctx, err, "list commands")
    +	}
     
     	var results []*fleet.MDMCommand
     	if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, listStmt, params...); err != nil {
    
  • server/datastore/mysql/mdm_test.go+155 0 modified
    @@ -37,6 +37,8 @@ func TestMDMShared(t *testing.T) {
     	}{
     		{"TestMDMCommands", testMDMCommands},
     		{"TestListMDMCommandsWithTeamFilter", testListMDMCommandsWithTeamFilter},
    +		{"TestListMDMCommandsOrderKeys", testListMDMCommandsOrderKeys},
    +		{"TestListMDMAppleCommandsOrderKeys", testListMDMAppleCommandsOrderKeys},
     		{"TestBatchSetMDMProfiles", testBatchSetMDMProfiles},
     		{"TestListMDMConfigProfiles", testListMDMConfigProfiles},
     		{"TestBulkSetPendingMDMHostProfiles", testBulkSetPendingMDMHostProfiles},
    @@ -584,6 +586,159 @@ func testListMDMCommandsWithTeamFilter(t *testing.T, ds *Datastore) {
     	require.ElementsMatch(t, []string{teamCmdUUID, globalCmdUUID}, got)
     }
     
    +func testListMDMCommandsOrderKeys(t *testing.T, ds *Datastore) {
    +	ctx := t.Context()
    +
    +	macH, err := ds.NewHost(ctx, &fleet.Host{
    +		Hostname:       "ord-host",
    +		OsqueryHostID:  ptr.String("ord-osq"),
    +		NodeKey:        ptr.String("ord-nk"),
    +		UUID:           uuid.NewString(),
    +		Platform:       "darwin",
    +		HardwareSerial: "ORDABC",
    +	})
    +	require.NoError(t, err)
    +	nanoEnroll(t, ds, macH, false)
    +
    +	commander, _ := createMDMAppleCommanderAndStorage(t, ds)
    +	for range 3 {
    +		err = commander.EnqueueCommand(ctx, []string{macH.UUID}, createRawAppleCmd("ProfileList", uuid.NewString()))
    +		require.NoError(t, err)
    +	}
    +
    +	for _, key := range []string{"command_uuid", "request_type", "status", "updated_at", "hostname", "host_uuid", "name"} {
    +		t.Run("order_"+key, func(t *testing.T) {
    +			cmds, _, _, err := ds.ListMDMCommands(
    +				ctx,
    +				fleet.TeamFilter{User: test.UserAdmin},
    +				&fleet.MDMCommandListOptions{
    +					ListOptions: fleet.ListOptions{OrderKey: key, PerPage: 5},
    +				},
    +			)
    +			require.NoError(t, err)
    +			require.Len(t, cmds, 3)
    +		})
    +	}
    +
    +	t.Run("rejects_unknown_key", func(t *testing.T) {
    +		_, _, _, err := ds.ListMDMCommands(
    +			ctx,
    +			fleet.TeamFilter{User: test.UserAdmin},
    +			&fleet.MDMCommandListOptions{
    +				ListOptions: fleet.ListOptions{OrderKey: "not_a_real_column"},
    +			},
    +		)
    +		require.Error(t, err)
    +	})
    +
    +	// the host-identifier branch uses a separate query; confirm it shares the allowlist
    +	t.Run("rejects_unknown_key_host_identifier", func(t *testing.T) {
    +		_, _, _, err := ds.ListMDMCommands(
    +			ctx,
    +			fleet.TeamFilter{User: test.UserAdmin},
    +			&fleet.MDMCommandListOptions{
    +				ListOptions: fleet.ListOptions{OrderKey: "not_a_real_column"},
    +				Filters:     fleet.MDMCommandFilters{HostIdentifier: macH.UUID},
    +			},
    +		)
    +		require.Error(t, err)
    +	})
    +
    +	t.Run("after_pagination_with_allowed_key", func(t *testing.T) {
    +		cmds, _, _, err := ds.ListMDMCommands(
    +			ctx,
    +			fleet.TeamFilter{User: test.UserAdmin},
    +			&fleet.MDMCommandListOptions{
    +				ListOptions: fleet.ListOptions{OrderKey: "command_uuid", PerPage: 1},
    +			},
    +		)
    +		require.NoError(t, err)
    +		require.Len(t, cmds, 1)
    +		afterCursor := cmds[0].CommandUUID
    +
    +		next, _, _, err := ds.ListMDMCommands(
    +			ctx,
    +			fleet.TeamFilter{User: test.UserAdmin},
    +			&fleet.MDMCommandListOptions{
    +				ListOptions: fleet.ListOptions{OrderKey: "command_uuid", PerPage: 1, After: afterCursor},
    +			},
    +		)
    +		require.NoError(t, err)
    +		require.Len(t, next, 1)
    +		require.NotEqual(t, afterCursor, next[0].CommandUUID)
    +	})
    +}
    +
    +func testListMDMAppleCommandsOrderKeys(t *testing.T, ds *Datastore) {
    +	ctx := t.Context()
    +
    +	macH, err := ds.NewHost(ctx, &fleet.Host{
    +		Hostname:       "ord-apple-host",
    +		OsqueryHostID:  ptr.String("ord-apple-osq"),
    +		NodeKey:        ptr.String("ord-apple-nk"),
    +		UUID:           uuid.NewString(),
    +		Platform:       "darwin",
    +		HardwareSerial: "ORDA1",
    +	})
    +	require.NoError(t, err)
    +	nanoEnroll(t, ds, macH, false)
    +
    +	commander, _ := createMDMAppleCommanderAndStorage(t, ds)
    +	for range 2 {
    +		err = commander.EnqueueCommand(ctx, []string{macH.UUID}, createRawAppleCmd("ProfileList", uuid.NewString()))
    +		require.NoError(t, err)
    +	}
    +
    +	for _, key := range []string{"command_uuid", "request_type", "status", "updated_at", "hostname", "device_id"} {
    +		t.Run("order_"+key, func(t *testing.T) {
    +			cmds, err := ds.ListMDMAppleCommands(
    +				ctx,
    +				fleet.TeamFilter{User: test.UserAdmin},
    +				&fleet.MDMCommandListOptions{
    +					ListOptions: fleet.ListOptions{OrderKey: key, PerPage: 5},
    +				},
    +			)
    +			require.NoError(t, err)
    +			require.Len(t, cmds, 2)
    +		})
    +	}
    +
    +	t.Run("rejects_unknown_key", func(t *testing.T) {
    +		_, err := ds.ListMDMAppleCommands(
    +			ctx,
    +			fleet.TeamFilter{User: test.UserAdmin},
    +			&fleet.MDMCommandListOptions{
    +				ListOptions: fleet.ListOptions{OrderKey: "not_a_real_column"},
    +			},
    +		)
    +		require.Error(t, err)
    +	})
    +
    +	t.Run("after_pagination_with_allowed_key", func(t *testing.T) {
    +		cmds, err := ds.ListMDMAppleCommands(
    +			ctx,
    +			fleet.TeamFilter{User: test.UserAdmin},
    +			&fleet.MDMCommandListOptions{
    +				ListOptions: fleet.ListOptions{OrderKey: "command_uuid", PerPage: 1},
    +			},
    +		)
    +		require.NoError(t, err)
    +		require.Len(t, cmds, 1)
    +		afterCursor := cmds[0].CommandUUID
    +
    +		next, err := ds.ListMDMAppleCommands(
    +			ctx,
    +			fleet.TeamFilter{User: test.UserAdmin},
    +			&fleet.MDMCommandListOptions{
    +				ListOptions: fleet.ListOptions{OrderKey: "command_uuid", PerPage: 1, After: afterCursor},
    +			},
    +		)
    +		require.NoError(t, err)
    +		require.Len(t, next, 1)
    +		require.NotEqual(t, afterCursor, next[0].CommandUUID)
    +	})
    +}
    +
     func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) {
     	ctx := context.Background()
     
    

Vulnerability mechanics

Root cause

"Missing validation of the user-supplied `order_key` parameter against a column allowlist allowed an attacker to order query results by any column from joined database tables, enabling a cursor-based binary-search oracle to extract sensitive values."

Attack vector

An attacker with authenticated Observer-role access sends requests to `GET /api/v1/fleet/mdm/apple/commands` with a crafted `order_key` parameter targeting a sensitive column such as `node_key`, `orbit_node_key`, or APNS tokens from the joined `hosts` or `nano_enrollments` tables. Using the cursor-based `after` pagination parameter, the attacker performs a binary search on the column's value one character at a time by observing whether results are returned. The targeted values never appear in the response body, but the presence or absence of results reveals each character. Exploitation requires Apple MDM to be enabled and at least one queued MDM command to exist.

Affected code

The vulnerability resides in the `GET /api/v1/fleet/mdm/apple/commands` endpoint and its underlying datastore methods `ListMDMAppleCommands` (`server/datastore/mysql/apple_mdm.go`) and `ListMDMCommands` (`server/datastore/mysql/mdm.go`). These methods used `appendListOptionsWithCursorToSQL` without validating the user-supplied `order_key` parameter against an allowlist, allowing any column from the joined `hosts` and `nano_enrollments` tables to be used in the `ORDER BY` clause.

What the fix does

The patch introduces two allowlist maps (`mdmCommandsAllowedOrderKeys` and `mdmAppleCommandsAllowedOrderKeys`) that enumerate the only columns permitted in the `ORDER BY` clause for each endpoint. The calls to `appendListOptionsWithCursorToSQL` are replaced with `appendListOptionsWithCursorToSQLSecure`, which validates the user-supplied `order_key` against the allowlist and returns an error if the key is not present. This prevents attackers from ordering by arbitrary columns from the joined tables, closing the binary-search oracle.

Preconditions

  • configFleet deployment must have Apple MDM enabled and at least one queued MDM command.
  • authAttacker must have authenticated access with the Observer role (lowest privilege).
  • networkThe endpoint must be reachable over the network.
  • inputAttacker supplies a crafted `order_key` parameter and uses the `after` cursor for binary search.

Generated on Jun 12, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.