From bb297c4fe39fa552e4a37549b30544f2c6b33aec Mon Sep 17 00:00:00 2001 From: Dwi Siswanto Date: Sun, 21 Jun 2026 03:01:04 +0700 Subject: [PATCH] fix(js): reject unsandboxed ccache writes in krbforge `OutputFile` was treated as a goimpacket option instead of a fs sink. Since goimpacket writes the ccache itself, krbforge had to enforce nuclei's local file policy before building the ticket config. Add execution-aware JS wrappers, normalize relative output paths into the template sandbox, and deny outside paths unless local file access is enabled. Keep empty output as in-memory only by passing "-". Signed-off-by: Dwi Siswanto --- pkg/js/libs/krbforge/krbforge.go | 190 +++++++++++++++++++++----- pkg/js/libs/krbforge/krbforge_test.go | 141 +++++++++++++++++++ 2 files changed, 297 insertions(+), 34 deletions(-) create mode 100644 pkg/js/libs/krbforge/krbforge_test.go diff --git a/pkg/js/libs/krbforge/krbforge.go b/pkg/js/libs/krbforge/krbforge.go index 8d6963e3bb..6125a23a80 100644 --- a/pkg/js/libs/krbforge/krbforge.go +++ b/pkg/js/libs/krbforge/krbforge.go @@ -13,6 +13,12 @@ import ( "path/filepath" gpkrb "github.com/Mzack9999/goimpacket/pkg/kerberos" + "github.com/projectdiscovery/goja" + "github.com/projectdiscovery/nuclei/v3/pkg/catalog/config" + "github.com/projectdiscovery/nuclei/v3/pkg/js/utils" + "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate" + "github.com/projectdiscovery/nuclei/v3/pkg/types" + filepathutil "github.com/projectdiscovery/nuclei/v3/pkg/utils/filepath" ) // TicketRequest mirrors gopacket's TicketConfig with json-friendly tags. @@ -43,34 +49,52 @@ type Ticket struct { // CreateGoldenTicket forges a TGT for the supplied user against the given // realm using the krbtgt NT hash (or AES key). It returns the ASN.1-encoded // ticket and the session key. If req.OutputFile is empty no file is written; -// pass an absolute path to also persist a ccache. +// pass an output path allowed by the nuclei file sandbox to also persist a +// ccache. Use -allow-local-file-access to allow writing outside the sandbox. // // @example // ```javascript // const krb = require('nuclei/krbforge'); -// const t = krb.CreateGoldenTicket({ -// username: 'Administrator', -// domain: 'acme.local', -// domain_sid: 'S-1-5-21-1004336348-1177238915-682003330', -// nthash: '31d6cfe0d16ae931b73c59d7e0c089c0', -// }); +// +// const t = krb.CreateGoldenTicket({ +// username: 'Administrator', +// domain: 'acme.local', +// domain_sid: 'S-1-5-21-1004336348-1177238915-682003330', +// nthash: '31d6cfe0d16ae931b73c59d7e0c089c0', +// }); +// // log(t.ticket_hex); // ``` -func CreateGoldenTicket(req TicketRequest) (*Ticket, error) { - cfg := buildConfig(req, "") - if cfg.OutputFile == "" { - cfg.OutputFile = "-" +func CreateGoldenTicket(call goja.FunctionCall, vm *goja.Runtime) goja.Value { + nj := utils.NewNucleiJS(vm) + nj.ObjectSig = "CreateGoldenTicket(request)" + + req, err := exportTicketRequest(vm, call.Argument(0)) + if err != nil { + nj.ThrowError(err) + return goja.Undefined() } - res, err := gpkrb.CreateTicket(cfg) + + ticket, err := createGoldenTicket(nj.ExecutionId(), req) + if err != nil { + nj.ThrowError(err) + return goja.Undefined() + } + + return vm.ToValue(ticket) +} + +func createGoldenTicket(executionID string, req TicketRequest) (*Ticket, error) { + cfg, err := buildConfig(executionID, req, "") if err != nil { return nil, err } - return &Ticket{ - HexTicket: hex.EncodeToString(res.Ticket), - HexKey: hex.EncodeToString(res.SessionKey), - EncType: res.EncType, - OutputFile: cfg.OutputFile, - }, nil + + if cfg.OutputFile == "" { + cfg.OutputFile = "-" + } + + return createTicket(cfg) } // CreateSilverTicket forges a service ticket (TGS) for the supplied SPN. The @@ -80,24 +104,65 @@ func CreateGoldenTicket(req TicketRequest) (*Ticket, error) { // @example // ```javascript // const krb = require('nuclei/krbforge'); -// const t = krb.CreateSilverTicket({ -// username: 'Administrator', -// domain: 'acme.local', -// domain_sid: 'S-1-5-21-1004336348-1177238915-682003330', -// nthash: '31d6cfe0d16ae931b73c59d7e0c089c0', -// spn: 'cifs/server01.acme.local', -// }, '/tmp/silver.ccache'); +// +// const t = krb.CreateSilverTicket({ +// username: 'Administrator', +// domain: 'acme.local', +// domain_sid: 'S-1-5-21-1004336348-1177238915-682003330', +// nthash: '31d6cfe0d16ae931b73c59d7e0c089c0', +// spn: 'cifs/server01.acme.local', +// }, '/tmp/silver.ccache'); +// // log(t.output_file); // ``` -func CreateSilverTicket(req TicketRequest, outputFile string) (*Ticket, error) { +func CreateSilverTicket(call goja.FunctionCall, vm *goja.Runtime) goja.Value { + nj := utils.NewNucleiJS(vm) + nj.ObjectSig = "CreateSilverTicket(request, outputFile)" + + req, err := exportTicketRequest(vm, call.Argument(0)) + if err != nil { + nj.ThrowError(err) + return goja.Undefined() + } + + outputFile, err := exportOutputFile(call.Argument(1)) + if err != nil { + nj.ThrowError(err) + return goja.Undefined() + } + + ticket, err := createSilverTicket(nj.ExecutionId(), req, outputFile) + if err != nil { + nj.ThrowError(err) + return goja.Undefined() + } + + return vm.ToValue(ticket) +} + +func createSilverTicket(executionID string, req TicketRequest, outputFile string) (*Ticket, error) { if req.SPN == "" { return nil, fmt.Errorf("spn is required for silver ticket") } - cfg := buildConfig(req, outputFile) + + cfg, err := buildConfig(executionID, req, outputFile) + if err != nil { + return nil, err + } + + if cfg.OutputFile == "" { + cfg.OutputFile = "-" + } + + return createTicket(cfg) +} + +func createTicket(cfg *gpkrb.TicketConfig) (*Ticket, error) { res, err := gpkrb.CreateTicket(cfg) if err != nil { return nil, err } + return &Ticket{ HexTicket: hex.EncodeToString(res.Ticket), HexKey: hex.EncodeToString(res.SessionKey), @@ -106,16 +171,16 @@ func CreateSilverTicket(req TicketRequest, outputFile string) (*Ticket, error) { }, nil } -func buildConfig(req TicketRequest, outputFile string) *gpkrb.TicketConfig { +func buildConfig(executionID string, req TicketRequest, outputFile string) (*gpkrb.TicketConfig, error) { if outputFile == "" { outputFile = req.OutputFile } - if outputFile != "" && outputFile != "-" { - // reject relative paths to keep the ccache out of CWD - if !filepath.IsAbs(outputFile) { - outputFile = filepath.Join(os.TempDir(), outputFile) - } + + normalizedOutputFile, err := normalizeOutputFile(executionID, outputFile) + if err != nil { + return nil, err } + return &gpkrb.TicketConfig{ Username: req.Username, Domain: req.Domain, @@ -129,6 +194,63 @@ func buildConfig(req TicketRequest, outputFile string) *gpkrb.TicketConfig { ExtraSIDs: req.ExtraSIDs, Duration: req.DurationHours, KVNO: req.KVNO, - OutputFile: outputFile, + OutputFile: normalizedOutputFile, + }, nil +} + +func normalizeOutputFile(executionID string, outputFile string) (string, error) { + if outputFile == "" || outputFile == "-" { + return outputFile, nil + } + + if protocolstate.IsLfaAllowed(&types.Options{ExecutionId: executionID}) { + // Preserve the existing relative-path behavior when + // -allow-local-file-access is enabled: avoid implicit CWD writes by + // placing relative ccache paths in temp. + if !filepath.IsAbs(outputFile) { + outputFile = filepath.Join(os.TempDir(), outputFile) + } + + normalized, err := filepath.Abs(outputFile) + if err != nil { + return "", fmt.Errorf("normalize output file %q: %w", outputFile, err) + } + + return normalized, nil + } + + normalized := outputFile + if !filepath.IsAbs(normalized) { + normalized = filepath.Join(config.DefaultConfig.GetTemplateDir(), normalized) + } + + normalized, err := filepath.Abs(normalized) + if err != nil { + return "", fmt.Errorf("normalize output file %q: %w", outputFile, err) + } + + if filepathutil.IsPathWithinDirectory(normalized, config.DefaultConfig.GetTemplateDir()) { + return normalized, nil + } + + return "", fmt.Errorf("path %v is outside nuclei-template directory and -allow-local-file-access is not enabled", outputFile) +} + +func exportTicketRequest(vm *goja.Runtime, value goja.Value) (TicketRequest, error) { + var req TicketRequest + if err := vm.ExportTo(value, &req); err != nil { + return req, fmt.Errorf("invalid TicketRequest: %w", err) + } + return req, nil +} + +func exportOutputFile(value goja.Value) (string, error) { + if goja.IsUndefined(value) || goja.IsNull(value) { + return "", nil + } + outputFile, ok := value.Export().(string) + if !ok { + return "", fmt.Errorf("outputFile must be a string") } + return outputFile, nil } diff --git a/pkg/js/libs/krbforge/krbforge_test.go b/pkg/js/libs/krbforge/krbforge_test.go new file mode 100644 index 0000000000..a311c042eb --- /dev/null +++ b/pkg/js/libs/krbforge/krbforge_test.go @@ -0,0 +1,141 @@ +package krbforge + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/projectdiscovery/goja" + "github.com/projectdiscovery/nuclei/v3/pkg/catalog/config" + "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate" + "github.com/projectdiscovery/nuclei/v3/pkg/types" + "github.com/stretchr/testify/require" +) + +func TestBuildConfigRejectsOutputFileOutsideSandbox(t *testing.T) { + setTemplateDir(t) + executionID := "deny-" + t.Name() + setLocalFileAccess(executionID, false) + outsidePath := filepath.Join(t.TempDir(), "ticket.ccache") + + t.Run("request output_file", func(t *testing.T) { + req := validTicketRequest() + req.OutputFile = outsidePath + + _, err := buildConfig(executionID, req, "") + require.Error(t, err) + require.Contains(t, err.Error(), "-allow-local-file-access is not enabled") + }) + + t.Run("silver outputFile argument", func(t *testing.T) { + _, err := buildConfig(executionID, validTicketRequest(), outsidePath) + require.Error(t, err) + require.Contains(t, err.Error(), "-allow-local-file-access is not enabled") + }) +} + +func TestBuildConfigAllowsOutputFileInsideSandbox(t *testing.T) { + templatesDir := setTemplateDir(t) + executionID := "sandbox-" + t.Name() + setLocalFileAccess(executionID, false) + + outputPath := filepath.Join(templatesDir, "generated", "ticket.ccache") + req := validTicketRequest() + req.OutputFile = outputPath + + cfg, err := buildConfig(executionID, req, "") + require.NoError(t, err) + require.Equal(t, outputPath, cfg.OutputFile) +} + +func TestBuildConfigNormalizesRelativeOutputFileInsideSandbox(t *testing.T) { + templatesDir := setTemplateDir(t) + executionID := "relative-" + t.Name() + setLocalFileAccess(executionID, false) + + cfg, err := buildConfig(executionID, validTicketRequest(), filepath.Join("generated", "ticket.ccache")) + require.NoError(t, err) + require.Equal(t, filepath.Join(templatesDir, "generated", "ticket.ccache"), cfg.OutputFile) +} + +func TestBuildConfigAllowsOutputFileWhenLocalFileAccessEnabled(t *testing.T) { + setTemplateDir(t) + executionID := "allow-" + t.Name() + setLocalFileAccess(executionID, true) + outsidePath := filepath.Join(t.TempDir(), "ticket.ccache") + + cfg, err := buildConfig(executionID, validTicketRequest(), outsidePath) + require.NoError(t, err) + require.Equal(t, outsidePath, cfg.OutputFile) +} + +func TestCreateGoldenTicketRejectsOutputFileOutsideSandboxFromRuntime(t *testing.T) { + setTemplateDir(t) + executionID := "runtime-" + t.Name() + setLocalFileAccess(executionID, false) + + runtime := goja.New() + runtime.SetContextValue("executionId", executionID) + + req := validTicketRequest() + req.OutputFile = filepath.Join(t.TempDir(), "ticket.ccache") + + var panicValue any + func() { + defer func() { + panicValue = recover() + }() + + CreateGoldenTicket(goja.FunctionCall{ + Arguments: []goja.Value{runtime.ToValue(req)}, + }, runtime) + }() + + require.NotNil(t, panicValue) + require.Contains(t, fmt.Sprint(panicValue), "-allow-local-file-access is not enabled") +} + +func TestCreateSilverTicketDoesNotWriteDefaultCCache(t *testing.T) { + setTemplateDir(t) + executionID := "silver-" + t.Name() + setLocalFileAccess(executionID, false) + + cwd := t.TempDir() + t.Chdir(cwd) + + ticket, err := createSilverTicket(executionID, validTicketRequest(), "") + require.NoError(t, err) + require.Equal(t, "-", ticket.OutputFile) + + _, err = os.Stat(filepath.Join(cwd, "Administrator.ccache")) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func setTemplateDir(t *testing.T) string { + t.Helper() + templatesDir := t.TempDir() + originalTemplatesDir := config.DefaultConfig.TemplatesDirectory + config.DefaultConfig.SetTemplatesDir(templatesDir) + t.Cleanup(func() { + config.DefaultConfig.SetTemplatesDir(originalTemplatesDir) + }) + return templatesDir +} + +func setLocalFileAccess(executionID string, allowed bool) { + protocolstate.SetLfaAllowed(&types.Options{ + ExecutionId: executionID, + AllowLocalFileAccess: allowed, + }) +} + +func validTicketRequest() TicketRequest { + return TicketRequest{ + Username: "Administrator", + Domain: "acme.local", + DomainSID: "S-1-5-21-1004336348-1177238915-682003330", + NTHash: "31d6cfe0d16ae931b73c59d7e0c089c0", + SPN: "cifs/server01.acme.local", + } +}