Repository navigation
JSONObject.toString() throws StackOverflowError (not JSONException) on self-referential cycles #1056
Description
Description
JSONObject.toString() (and write()) recurse into nested JSONObject values without any cycle detection. If a JSONObject contains itself (directly or transitively), serialization recurses indefinitely and the JVM throws StackOverflowError.
The parsing path is protected by JSONParserConfiguration.getMaxNestingDepth(), but cycles created via put() programmatically bypass it because the cycle was never parsed.
Reproducer (org.json 20240303)
import org.json.JSONObject;
public class Repro {
public static void main(String[] args) {
JSONObject jo = new JSONObject();
jo.put("key", "value");
jo.put("self", jo); // direct self-reference
jo.toString(); // -> StackOverflowError
}
}Indirect cycles also trigger:
JSONObject a = new JSONObject(), b = new JSONObject();
a.put("b", b);
b.put("a", a);
a.toString(); // -> StackOverflowErrorJSONArray containing itself triggers the same:
JSONArray arr = new JSONArray();
arr.put("x");
arr.put(arr);
arr.toString(); // -> StackOverflowErrorWhy this is more than "don't construct cycles"
- Code that takes user input and walks it into a
JSONObjectmodel (deserializers, GraphQL resolvers, ORM emitters) may produce a cycle without realizing it (object graph derived from a database join, a mutually-referencing config). - The current contract is that
JSONObject.toString()returns aStringor throws a checkedJSONException. AStackOverflowErroris anError, not anException, so application try/catch blocks targetingException(or evenJSONException) won't catch it. The JVM thread crashes. - A library used in a hot serialization path that crashes on
Errorrather than throwing a typed exception is a DoS / availability issue for any process that lets this be reached.
Root cause
JSONObject.writeValue(Writer, Object, …) and JSONArray.write(…) recurse on nested values without maintaining a "seen" set. The fix is to either:
- Pass an
IdentityHashMap<Object, Boolean>of currently-being-serialized objects down throughwrite()/writeValue()and throwJSONExceptionon a cycle. - Use the same
maxNestingDepthlimit on serialization that already exists on parsing.
(1) is more precise; (2) is simpler and matches the parsing-side mitigation.
Suggested patch sketch
public Writer writeValue(Writer writer, Object value, int indentFactor, int indent,
Set<Object> seen) throws JSONException, IOException {
if (value instanceof JSONObject || value instanceof JSONArray) {
if (!seen.add(System.identityHashCode(value))) {
throw new JSONException("Cyclic reference detected during serialization");
}
try {
// existing logic, threading `seen` into recursive calls
} finally {
seen.remove(System.identityHashCode(value));
}
} else { /* unchanged */ }
}Environment
- org.json: 20240303 (latest at time of writing)
- JDK: 21
Discovered via jqwik property-based testing on the invariant toString() either returns a String or throws JSONException (never Error). Happy to PR.
Activity
stleary commentedon May 28, 2026
@zhangjiashuo-cs Thanks for submitting this issue. The JavaDocs caution users in multiple places that cyclic JSON is not supported, but I still think it would be worthwhile to prevent stackoverflow exceptions. Feel free to submit a PR to address this problem.
JeewoongKim commentedon Jul 11, 2026
I found a related
StackOverflowErrorwhile testing a new fuzz target for JSON-java.This seems related to the recursive serialization path discussed here, but the trigger
appears to be different from the self-referential cycle case.
The fuzz target parses a JSON value from input and then serializes it again:
Since the value is produced by parsing JSON text, this is not a programmatically constructed
cyclic
JSONObject/JSONArraycreated viaput(). The crashing input appears to be a deeplynested but acyclic JSON value.
Here is the relevant stack trace:
The saved crashing input reproduces when replayed with a constrained JVM stack size:
JAVA_TOOL_OPTIONS="-Xss921k" ./MyFuzzer ./crash-input -runs=1I may be missing some JSON-java specific context, but after looking at the failing call path,
my current understanding is that this is not only a cycle-detection issue. The same recursive
serialization path can also be reached from a deeply nested parsed JSON value. So it may be
worth considering a maximum nesting depth check on the serialization side as well.
I am not sure whether this should be handled as part of this issue or tracked separately as a
serialization-depth issue for deeply nested parsed JSON values. I would be happy to open
a separate issue if that would be clearer.
The crashing input is intentionally not attached publicly because this may have availability / DoS implications.
I can provide a minimized input or a standalone reproducer if helpful.
Edit:
This was reproduced against the JSON-java commit 1efb5f6.
stleary commentedon Jul 11, 2026
@JeewoongKim Thanks for catching this, I will see if it can be included in the fix.