[
https://issues.apache.org/jira/browse/GROOVY-12109?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18092027#comment-18092027
]
ASF GitHub Bot commented on GROOVY-12109:
-----------------------------------------
Copilot commented on code in PR #2634:
URL: https://github.com/apache/groovy/pull/2634#discussion_r3485966147
##########
src/main/java/groovy/lang/Closure.java:
##########
@@ -1293,6 +1300,74 @@ public Closure<V> rehydrate(Object delegate, Object
owner, Object thisObject) {
return result;
}
+ /**
+ * Deserialization hook that rejects a closure whose {@code owner}/{@code
delegate}/{@code thisObject}
+ * references form a cycle through other closures. Such a cycle cannot
arise from normal Groovy code
+ * or from the {@link #dehydrate()}/{@link #rehydrate} round-trip, but it
can be forged in a
+ * hand-crafted serialized stream; invoking the resulting closure would
then recurse indefinitely and
+ * exhaust the stack (a denial-of-service "gadget").
+ * <p>
+ * This is implemented as {@code readResolve} rather than {@code
readObject} on purpose: a custom
+ * {@code readObject} would interpose this (core-loaded) class on the
stack while the closure's fields
+ * are being read, shifting {@link java.io.ObjectInputStream}'s
latest-user-defined-loader away from the
+ * loader that defined the closure's captured types and breaking
otherwise-valid deserialization.
+ * {@code readResolve} runs after the fields have been read by the default
mechanism, so it does not
+ * affect class resolution.
+ *
+ * @return this closure, unchanged, once validated
+ */
+ @Serial
+ protected Object readResolve() throws ObjectStreamException {
+ // Iterative depth-first search over the raw owner/delegate/thisObject
fields, following only
+ // Closure-valued links. "grey" = on the current DFS path, "black" =
fully explored. An edge back
+ // to a grey node is a genuine cycle; an edge to a black node is a
harmless shared reference (e.g.
+ // a curried closure whose owner and delegate point at the same
wrapped closure). The raw fields
+ // are read directly (not via the overridable getters, which would
themselves recurse on a cyclic
+ // graph); access is permitted as this is the declaring class.
+ final Map<Closure<?>, Boolean> grey = new IdentityHashMap<>();
+ final Set<Closure<?>> black = Collections.newSetFromMap(new
IdentityHashMap<>());
+ final Deque<Object> stack = new ArrayDeque<>();
Review Comment:
In readResolve(), the "grey" structure is used only as a key-set (values are
never read). Using an identity-backed Set like the "black" set would simplify
the code and avoid the unused Boolean values.
##########
src/main/java/groovy/lang/Closure.java:
##########
@@ -1293,6 +1300,74 @@ public Closure<V> rehydrate(Object delegate, Object
owner, Object thisObject) {
return result;
}
+ /**
+ * Deserialization hook that rejects a closure whose {@code owner}/{@code
delegate}/{@code thisObject}
+ * references form a cycle through other closures. Such a cycle cannot
arise from normal Groovy code
+ * or from the {@link #dehydrate()}/{@link #rehydrate} round-trip, but it
can be forged in a
+ * hand-crafted serialized stream; invoking the resulting closure would
then recurse indefinitely and
+ * exhaust the stack (a denial-of-service "gadget").
+ * <p>
+ * This is implemented as {@code readResolve} rather than {@code
readObject} on purpose: a custom
+ * {@code readObject} would interpose this (core-loaded) class on the
stack while the closure's fields
+ * are being read, shifting {@link java.io.ObjectInputStream}'s
latest-user-defined-loader away from the
+ * loader that defined the closure's captured types and breaking
otherwise-valid deserialization.
+ * {@code readResolve} runs after the fields have been read by the default
mechanism, so it does not
+ * affect class resolution.
+ *
+ * @return this closure, unchanged, once validated
+ */
+ @Serial
+ protected Object readResolve() throws ObjectStreamException {
+ // Iterative depth-first search over the raw owner/delegate/thisObject
fields, following only
Review Comment:
Making Closure.readResolve() protected introduces a new overridable method
on a widely-subclassed public type; any existing subclass that already declares
a private readResolve() (a common Java-serialization pattern) will now fail to
compile due to reduced visibility. Consider keeping the serialization hook
private in Closure and delegating the validation logic to a separate
protected/package-private helper for Groovy-internal subclasses that need to
participate.
> Harden Closure deserialization against owner/delegate reference cycle
> ---------------------------------------------------------------------
>
> Key: GROOVY-12109
> URL: https://issues.apache.org/jira/browse/GROOVY-12109
> Project: Groovy
> Issue Type: Improvement
> Reporter: Paul King
> Assignee: Paul King
> Priority: Major
>
> h2. Reject Closure owner/delegate reference cycles during deserialization
> h3. Cause (confirmed against groovy-5.0.6)
> A cyclic {{owner}}/{{delegate}} graph recurses forever through the MOP:
> {{CurriedClosure.call -> getOwner().call -> (owner is self) -> call -> ...}}.
> Or this similar pure-Groovy shape {{cl.delegate = cl; cl()}}. The graph
> serializes and deserializes cleanly; the blow-up is on first *invocation*.
> The only attacker-controlled way to build the cycle is a forged stream, so it
> is rejected there.
> h3. Options ruled out (break tested behaviour)
> || Option || Why it fails ||
> | Remove {{Serializable}} from {{Closure}} | Breaks all closure serialization
> + real users |
> | Make {{owner}}/{{delegate}}/{{thisObject}} {{transient}} | Fields come back
> {{null}}; {{Groovy11272.testSerialVersionUID_2/3}} serialize _without_
> {{dehydrate()}} and require the round-trip to stay callable |
> h3. Fix
> A {{readResolve}} hook on {{groovy.lang.Closure}} runs an iterative
> grey/black DFS over the raw {{owner}}/{{delegate}}/{{thisObject}} fields
> (Closure-valued links only) and throws {{InvalidObjectException}} on any
> cycle.
> Key design points:
> * *{{readResolve}}, not {{readObject}}* — a custom {{readObject}} interposes
> a core-loaded frame during field reads, shifting
> {{ObjectInputStream.latestUserDefinedLoader()}} off the script's
> {{GroovyClassLoader}} and breaking deserialization of captured generated
> types (caught by {{IntersectionClosureLiteralTest}}). {{readResolve}} runs
> after fields are read, so class resolution is untouched.
> * *Walk raw fields, not getters* — {{CurriedClosure.getDelegate()}} calls
> {{getOwner().getDelegate()}}, which would itself StackOverflow on the
> malicious graph.
> * *Grey/black DFS, not plain "visited"* — a curried closure's {{owner}} and
> {{delegate}} share the same wrapped clone (a diamond, not a cycle); naive
> detection false-positives on every curried closure.
> * {{MethodClosure}} already hard-blocks deserialization (CVE-2015-3253); its
> {{readResolve}} is only widened {{private}} -> {{protected}} to override the
> new hook (behaviour unchanged).
> h3. Validation
> || Graph || Detected? || Want ||
> | self-cycle ({{owner = this}}) — the PoC | (/) yes | yes |
> | A<->B two-closure cycle | (/) yes | yes |
> | legit simple closure | (/) no | no |
> | legit curried closure (shared clone) | (/) no | no |
> * New {{groovy.lang.ClosureSerializationCycleTest}} (4 tests): self-cycle
> rejected, A<->B cycle rejected, legit closure + legit curried closure
> round-trip.
> * Regression: previously-failing {{IntersectionClosureLiteralTest}} now
> passes; broad closure/serialization sweep all green.
> h3. Residual risk (by design)
> Does *not* prevent self-inflicted pure-Groovy recursion ({{cl.delegate = cl;
> cl()}}) — not a vulnerability, and scoped out deliberately. Hardening targets
> only the deserialization vector.
> h3. Change inventory
> || File || Change ||
> | {{groovy/lang/Closure.java}} | {{readResolve}} cycle check + private
> {{Marker}} sentinel; 3 new imports |
> | {{runtime/MethodClosure.java}} | {{readResolve}} {{private}} ->
> {{protected}} (behaviour unchanged) |
> | {{groovy/lang/ClosureSerializationCycleTest.groovy}} (NEW) | 4 tests |
> h3. Recommendation
> # Adopt the cycle-rejecting {{readResolve}} guard over a self-reference-only
> check (closes A<->B at no extra cost).
> # Decide the user-facing {{InvalidObjectException}} wording and whether a
> changelog note is warranted.
> # Run a full CI {{:test}} before merge — this path now runs on _every_
> closure deserialization.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)