mirror of
https://github.com/projectdiscovery/nuclei.git
synced 2026-11-07 09:46:03 -10:00
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:
1 parent
4c03c29e45
commit
3a8b65cb45
2 files changed
+297
-34
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user