mirror of
https://github.com/projectdiscovery/nuclei
synced 2026-06-08 16:50:47 +00:00
fa73d2e13e
Prevent pooled and non-pooled goja runtimes from being reused or cleaned up when the `RunProgram` goroutine fails to exit after context cancellation and `Interrupt()`. In that state, the goroutine may still be mutating runtime state, so touching the runtime/returning it to the pool can cause fatal concurrent map access panics. Add an explicit `errRuntimeTerminationTimeout` path that abandons the runtime, keeps the related concurrency slot reserved, and releases that slot only from a reaper after the orphaned goroutine exits. Preserve `errors.Is` compatibility with the original context cancellation cause. Keep normal cleanup behavior unchanged, including callback panic cleanup, and add regression tests for stuck-interrupt handling that verify runtime abandonment and delayed slot release. Fixes #7376 * fix race condition * fixing review comments * fix(js): defer runtime cleanup When a runtime is abandoned, we must not touch the goja runtime after `RunProgram` has exceeded the interrupt grace period. At the same time, cleanup still needs to run on every normal exit before returning the runtime to the pool, including setup callback panics. Signed-off-by: Dwi Siswanto <git@dw1.io> --------- Signed-off-by: Dwi Siswanto <git@dw1.io> Co-authored-by: Dwi Siswanto <git@dw1.io>
397 lines
14 KiB
Go
397 lines
14 KiB
Go
package compiler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Mzack9999/goja"
|
|
"github.com/Mzack9999/goja_nodejs/console"
|
|
"github.com/Mzack9999/goja_nodejs/require"
|
|
"github.com/projectdiscovery/gologger"
|
|
stringsutil "github.com/projectdiscovery/utils/strings"
|
|
syncutil "github.com/projectdiscovery/utils/sync"
|
|
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libbytes"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libdcerpc"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libdcom"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libfs"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libikev2"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libkerberos"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libkrbforge"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libkrbroast"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libldap"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libmssql"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libmysql"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libnet"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/liboracle"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libpop3"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libpostgres"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/librdp"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libredis"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/librsync"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libscmr"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libsecretsdump"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libsmb"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libsmtp"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libssh"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libstructs"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libtelnet"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libtsch"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libvnc"
|
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libwmi"
|
|
"github.com/projectdiscovery/nuclei/v3/pkg/js/global"
|
|
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
|
|
"github.com/projectdiscovery/nuclei/v3/pkg/js/libs/goconsole"
|
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
|
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
|
|
)
|
|
|
|
const (
|
|
exportToken = "Export"
|
|
exportAsToken = "ExportAs"
|
|
)
|
|
|
|
type gojaRunResult struct {
|
|
result goja.Value
|
|
err error
|
|
}
|
|
|
|
// errRuntimeTerminationTimeout is returned by executeWithRuntime when the
|
|
// goroutine running goja.Runtime.RunProgram fails to terminate within the
|
|
// grace period after an interrupt was raised. When this error is returned
|
|
// the runtime MUST NOT be touched (no cleanup, no reuse, no Put back to
|
|
// the pool) because the orphaned goroutine is still operating on it; any
|
|
// concurrent access would race with goja's per-runtime state and can
|
|
// trigger a fatal "concurrent map read and map write" runtime panic.
|
|
//
|
|
// See https://github.com/projectdiscovery/nuclei/issues/7376.
|
|
var errRuntimeTerminationTimeout = errors.New("nuclei js runtime: program failed to terminate after interrupt")
|
|
|
|
var (
|
|
lazyRegistryInit = sync.OnceFunc(func() {
|
|
// autoregister console node module with default printer it uses gologger backend
|
|
require.RegisterNativeModule(console.ModuleName, console.RequireWithPrinter(goconsole.NewGoConsolePrinter()))
|
|
})
|
|
pooljsc *syncutil.AdaptiveWaitGroup
|
|
lazySgInit = sync.OnceFunc(func() {
|
|
pooljsc, _ = syncutil.New(syncutil.WithSize(PoolingJsVmConcurrency))
|
|
})
|
|
sgResizeCheck = func(ctx context.Context) {
|
|
// resize check point
|
|
if pooljsc.Size != PoolingJsVmConcurrency {
|
|
if err := pooljsc.Resize(ctx, PoolingJsVmConcurrency); err != nil {
|
|
gologger.Warning().Msgf("Could not resize workpool: %s\n", err)
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
var gojapool = &sync.Pool{
|
|
New: func() interface{} {
|
|
return createNewRuntime()
|
|
},
|
|
}
|
|
|
|
// executeWithRuntime runs program p on runtime. When the goroutine that
|
|
// actually executes the program fails to terminate within the grace period
|
|
// after a context-driven interrupt, the runtime is abandoned (see
|
|
// errRuntimeTerminationTimeout) and onOrphanExit is invoked from a reaper
|
|
// goroutine once the orphaned goroutine eventually returns. Callers that
|
|
// hold a concurrency slot or other resource on behalf of this runtime can
|
|
// use onOrphanExit to defer the release of that resource until the orphan
|
|
// is truly gone, instead of freeing it eagerly and letting fresh callers
|
|
// race the abandoned runtime. Pass nil if there is nothing to release.
|
|
func executeWithRuntime(ctx context.Context, runtime *goja.Runtime, p *goja.Program, args *ExecuteArgs, opts *ExecuteOptions, onOrphanExit func()) (goja.Value, error) {
|
|
if args == nil {
|
|
args = NewExecuteArgs()
|
|
}
|
|
if opts == nil {
|
|
opts = &ExecuteOptions{}
|
|
}
|
|
|
|
runtimeAbandoned := false
|
|
defer func() {
|
|
if runtimeAbandoned {
|
|
return
|
|
}
|
|
cleanupRuntime(runtime, args, opts)
|
|
}()
|
|
|
|
runtime.ClearInterrupt()
|
|
|
|
// set template ctx
|
|
_ = runtime.Set("template", args.TemplateCtx)
|
|
// set args
|
|
for k, v := range args.Args {
|
|
_ = runtime.Set(k, v)
|
|
}
|
|
|
|
runtime.SetContextValue("executionId", opts.ExecutionId)
|
|
runtime.SetContextValue("ctx", ctx)
|
|
enableRequire(runtime)
|
|
|
|
// register extra callbacks if any
|
|
if opts.Callback != nil {
|
|
if err := opts.Callback(runtime); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
resultChan := make(chan gojaRunResult, 1)
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
resultChan <- gojaRunResult{err: fmt.Errorf("panic: %s", r)}
|
|
}
|
|
}()
|
|
|
|
result, err := runtime.RunProgram(p)
|
|
resultChan <- gojaRunResult{result, err}
|
|
}()
|
|
|
|
var r gojaRunResult
|
|
select {
|
|
case <-ctx.Done():
|
|
runtime.Interrupt(ctx.Err())
|
|
select {
|
|
case r = <-resultChan:
|
|
// inner goroutine terminated cleanly after the interrupt
|
|
case <-time.After(time.Second):
|
|
// The goroutine running RunProgram is still alive even after
|
|
// being interrupted. We MUST NOT touch the runtime any more —
|
|
// doing so (cleanup, reuse, Put back to the pool) would race
|
|
// with the orphan goroutine still mutating goja's per-runtime
|
|
// state and trigger a fatal map race. Hand the runtime off to
|
|
// a reaper goroutine that waits for the orphan to actually
|
|
// finish before releasing any caller-owned resource (typically
|
|
// the pool concurrency slot). If the orphan never finishes
|
|
// (e.g. a native callback that blocks forever) the slot stays
|
|
// held — which is exactly the behaviour we want, because the
|
|
// stuck callback is still consuming runtime resources.
|
|
runtimeAbandoned = true
|
|
if onOrphanExit != nil {
|
|
go func() {
|
|
<-resultChan
|
|
onOrphanExit()
|
|
}()
|
|
}
|
|
return nil, fmt.Errorf("%w: %w", errRuntimeTerminationTimeout, ctx.Err())
|
|
}
|
|
case r = <-resultChan:
|
|
// normal termination
|
|
}
|
|
|
|
// At this point the inner goroutine has returned, so it is safe to
|
|
// touch the runtime again from this goroutine.
|
|
return r.result, r.err
|
|
}
|
|
|
|
// cleanupRuntime resets the per-execution state of a goja runtime so that
|
|
// it can be safely reused by a subsequent caller. It MUST only be called
|
|
// once the goroutine that ran RunProgram on this runtime has returned;
|
|
// otherwise it races with that goroutine on goja's internal maps.
|
|
func cleanupRuntime(runtime *goja.Runtime, args *ExecuteArgs, opts *ExecuteOptions) {
|
|
_ = runtime.GlobalObject().Delete("template") // template ctx
|
|
for k := range args.Args {
|
|
_ = runtime.GlobalObject().Delete(k)
|
|
}
|
|
if opts != nil && opts.Cleanup != nil {
|
|
opts.Cleanup(runtime)
|
|
}
|
|
runtime.RemoveContextValue("executionId")
|
|
runtime.RemoveContextValue("ctx")
|
|
}
|
|
|
|
// ExecuteProgram executes a compiled program with the default options.
|
|
// it deligates if a particular program should run in a pooled or non-pooled runtime
|
|
func ExecuteProgram(ctx context.Context, p *goja.Program, args *ExecuteArgs, opts *ExecuteOptions) (goja.Value, error) {
|
|
if opts.Source == nil {
|
|
// not-recommended anymore
|
|
return executeWithoutPooling(ctx, p, args, opts)
|
|
}
|
|
if !stringsutil.ContainsAny(*opts.Source, exportAsToken, exportToken) {
|
|
// not-recommended anymore
|
|
return executeWithoutPooling(ctx, p, args, opts)
|
|
}
|
|
return executeWithPoolingProgram(ctx, p, args, opts)
|
|
}
|
|
|
|
// executes the actual js program
|
|
func executeWithPoolingProgram(ctx context.Context, p *goja.Program, args *ExecuteArgs, opts *ExecuteOptions) (goja.Value, error) {
|
|
// its unknown (most likely cannot be done) to limit max js runtimes at a moment without making it static
|
|
// unlike sync.Pool which reacts to GC and its purposes is to reuse objects rather than creating new ones
|
|
lazySgInit()
|
|
sgResizeCheck(ctx)
|
|
|
|
// Acquire a pool slot, respecting the execution deadline. Returns
|
|
// immediately if the context has already expired.
|
|
if err := pooljsc.AddWithContext(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
runtime := gojapool.Get().(*goja.Runtime)
|
|
// runtimeAbandoned is set to true when executeWithRuntime returns
|
|
// errRuntimeTerminationTimeout, signalling that an orphan goroutine
|
|
// is still running on this runtime. In that case we drop the runtime
|
|
// instead of returning it to the pool (Go's GC will reclaim it once
|
|
// the orphan goroutine eventually exits) and we transfer ownership
|
|
// of the concurrency slot to a reaper goroutine inside
|
|
// executeWithRuntime, which releases it via pooljsc.Done once the
|
|
// orphan actually exits. Releasing the slot eagerly here would let a
|
|
// stream of stuck callbacks bypass PoolingJsVmConcurrency and trade
|
|
// the original map-race crash for unbounded resource growth.
|
|
runtimeAbandoned := false
|
|
defer func() {
|
|
if runtimeAbandoned {
|
|
return
|
|
}
|
|
gojapool.Put(runtime)
|
|
pooljsc.Done()
|
|
}()
|
|
|
|
var buff bytes.Buffer
|
|
opts.exports = make(map[string]interface{})
|
|
|
|
defer func() {
|
|
if runtimeAbandoned {
|
|
// Don't touch the runtime: the orphan goroutine still owns it.
|
|
return
|
|
}
|
|
// remove below functions from runtime
|
|
_ = runtime.GlobalObject().Delete(exportAsToken)
|
|
_ = runtime.GlobalObject().Delete(exportToken)
|
|
}()
|
|
|
|
// register export functions
|
|
_ = gojs.RegisterFuncWithSignature(runtime, gojs.FuncOpts{
|
|
Name: "Export", // we use string instead of const for documentation generation
|
|
Signatures: []string{"Export(value any)"},
|
|
Description: "Converts a given value to a string and is appended to output of script",
|
|
FuncDecl: func(call goja.FunctionCall, runtime *goja.Runtime) goja.Value {
|
|
if len(call.Arguments) == 0 {
|
|
return goja.Null()
|
|
}
|
|
for _, arg := range call.Arguments {
|
|
if out := stringify(arg, runtime); out != "" {
|
|
buff.WriteString(out)
|
|
}
|
|
}
|
|
return goja.Null()
|
|
},
|
|
})
|
|
// register exportAs function
|
|
_ = gojs.RegisterFuncWithSignature(runtime, gojs.FuncOpts{
|
|
Name: "ExportAs", // Export
|
|
Signatures: []string{"ExportAs(key string,value any)"},
|
|
Description: "Exports given value with specified key and makes it available in DSL and response",
|
|
FuncDecl: func(call goja.FunctionCall, runtime *goja.Runtime) goja.Value {
|
|
if len(call.Arguments) != 2 {
|
|
// this is how goja expects errors to be returned
|
|
// and internally it is done same way for all errors
|
|
panic(runtime.ToValue("ExportAs expects 2 arguments"))
|
|
}
|
|
key := call.Argument(0).String()
|
|
value := call.Argument(1)
|
|
opts.exports[key] = stringify(value, runtime)
|
|
return goja.Null()
|
|
},
|
|
})
|
|
|
|
val, err := executeWithRuntime(ctx, runtime, p, args, opts, pooljsc.Done)
|
|
if err != nil {
|
|
if errors.Is(err, errRuntimeTerminationTimeout) {
|
|
runtimeAbandoned = true
|
|
gologger.Warning().Msgf("js runtime did not terminate after interrupt; abandoning it to avoid concurrent use: %s", err)
|
|
}
|
|
return nil, err
|
|
}
|
|
if val.Export() != nil {
|
|
// append last value to output
|
|
buff.WriteString(stringify(val, runtime))
|
|
}
|
|
// and return it as result
|
|
return runtime.ToValue(buff.String()), nil
|
|
}
|
|
|
|
// Internal purposes i.e generating bindings
|
|
func InternalGetGeneratorRuntime() *goja.Runtime {
|
|
runtime := gojapool.Get().(*goja.Runtime)
|
|
return runtime
|
|
}
|
|
|
|
func enableRequire(runtime *goja.Runtime) {
|
|
lazyRegistryInit()
|
|
_ = require.NewRegistry(require.WithLoader(newSourceLoader(runtime))).Enable(runtime)
|
|
}
|
|
|
|
func newSourceLoader(runtime *goja.Runtime) require.SourceLoader {
|
|
return func(path string) ([]byte, error) {
|
|
executionID := ""
|
|
if value, ok := runtime.GetContextValue("executionId"); ok {
|
|
if id, ok := value.(string); ok {
|
|
executionID = id
|
|
}
|
|
}
|
|
|
|
normalizedPath, err := protocolstate.NormalizePathWithExecutionId(executionID, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return require.DefaultSourceLoader(normalizedPath)
|
|
}
|
|
}
|
|
|
|
func createNewRuntime() *goja.Runtime {
|
|
runtime := protocolstate.NewJSRuntime()
|
|
enableRequire(runtime)
|
|
// by default import below modules every time
|
|
_ = runtime.Set("console", require.Require(runtime, console.ModuleName))
|
|
|
|
// Register embedded javascript helpers
|
|
if err := global.RegisterNativeScripts(runtime); err != nil {
|
|
gologger.Error().Msgf("Could not register scripts: %s\n", err)
|
|
}
|
|
return runtime
|
|
}
|
|
|
|
// stringify converts a given value to string
|
|
// if its a struct it will be marshalled to json
|
|
func stringify(gojaValue goja.Value, runtime *goja.Runtime) string {
|
|
value := gojaValue.Export()
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
kind := reflect.TypeOf(value).Kind()
|
|
if kind == reflect.Struct || kind == reflect.Pointer && reflect.ValueOf(value).Elem().Kind() == reflect.Struct {
|
|
// in this case we must use JSON.stringify to convert to string
|
|
// because json.Marshal() utilizes json tags when marshalling
|
|
// but goja has custom implementation of json.Marshal() which does not
|
|
// since we have been using `to_json` in all our examples we must stick to it
|
|
// marshal structs or struct pointers to json automatically
|
|
jsonStringify, ok := goja.AssertFunction(runtime.Get("to_json"))
|
|
if ok {
|
|
result, err := jsonStringify(goja.Undefined(), gojaValue)
|
|
if err == nil {
|
|
return result.String()
|
|
}
|
|
}
|
|
// unlikely but if to_json threw some error use native json.Marshal
|
|
val := value
|
|
if kind == reflect.Pointer {
|
|
val = reflect.ValueOf(value).Elem().Interface()
|
|
}
|
|
bin, err := json.Marshal(val)
|
|
if err == nil {
|
|
return string(bin)
|
|
}
|
|
}
|
|
// for everything else stringify
|
|
return fmt.Sprintf("%+v", value)
|
|
}
|