fix(js): reject unsandboxed ccache writes in krbforge (#7482)

`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 <[email protected]>
This commit is contained in:
Dwi Siswanto authored and GitHub committed 2026-06-23 22:36:57 +02:00
1 parent 4c03c29e45
commit 3a8b65cb45
2 files changed
+297 -34

No files matched your search

+156 -34
View File
@@ -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
}
+141
View File
@@ -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",
}
}