From 891dffb4a14cd9dd04908e2cf2f3bc248855d04d Mon Sep 17 00:00:00 2001 From: Mzack9999 Date: Wed, 31 Dec 2025 23:02:48 +0400 Subject: [PATCH] feat(js): adds RSYNC module (#6410) * adding min auth support * adding unauth list modules + auth list files in module * example * adding rsync test * bump go.mod --------- Co-authored-by: Dwi Siswanto --- cmd/integration-test/javascript.go | 52 +++++- go.mod | 2 + go.sum | 4 + .../protocols/javascript/rsync-test.yaml | 21 +++ pkg/js/generated/go/librsync/rsync.go | 1 + pkg/js/generated/ts/rsync.ts | 56 ++++++- pkg/js/libs/rsync/rsync.go | 155 +++++++++++++++++- 7 files changed, 283 insertions(+), 8 deletions(-) create mode 100644 integration_tests/protocols/javascript/rsync-test.yaml diff --git a/cmd/integration-test/javascript.go b/cmd/integration-test/javascript.go index 63b2d59e8..c85a8758a 100644 --- a/cmd/integration-test/javascript.go +++ b/cmd/integration-test/javascript.go @@ -15,6 +15,7 @@ var jsTestcases = []TestCaseInfo{ {Path: "protocols/javascript/ssh-server-fingerprint.yaml", TestCase: &javascriptSSHServerFingerprint{}, DisableOn: func() bool { return osutils.IsWindows() || osutils.IsOSX() }}, {Path: "protocols/javascript/net-multi-step.yaml", TestCase: &networkMultiStep{}}, {Path: "protocols/javascript/net-https.yaml", TestCase: &javascriptNetHttps{}}, + {Path: "protocols/javascript/rsync-test.yaml", TestCase: &javascriptRsyncTest{}, DisableOn: func() bool { return osutils.IsWindows() || osutils.IsOSX() }}, {Path: "protocols/javascript/oracle-auth-test.yaml", TestCase: &javascriptOracleAuthTest{}, DisableOn: func() bool { return osutils.IsWindows() || osutils.IsOSX() }}, {Path: "protocols/javascript/vnc-pass-brute.yaml", TestCase: &javascriptVncPassBrute{}}, {Path: "protocols/javascript/postgres-pass-brute.yaml", TestCase: &javascriptPostgresPassBrute{}, DisableOn: func() bool { return osutils.IsWindows() || osutils.IsOSX() }}, @@ -30,6 +31,7 @@ var ( vncResource *dockertest.Resource postgresResource *dockertest.Resource mysqlResource *dockertest.Resource + rsyncResource *dockertest.Resource pool *dockertest.Pool defaultRetry = 3 ) @@ -124,7 +126,7 @@ func (j *javascriptOracleAuthTest) Execute(filePath string) error { results := []string{} var err error _ = pool.Retry(func() error { - //let ssh server start + // let oracle server start time.Sleep(3 * time.Second) results, err = testutils.RunNucleiTemplateAndGetResults(filePath, finalURL, debug) return nil @@ -258,6 +260,38 @@ func (j *javascriptNoPortArgs) Execute(filePath string) error { return expectResultsCount(results, 1) } +type javascriptRsyncTest struct{} + +func (j *javascriptRsyncTest) Execute(filePath string) error { + if rsyncResource == nil || pool == nil { + // skip test as rsync is not running + return nil + } + tempPort := rsyncResource.GetPort("873/tcp") + finalURL := "localhost:" + tempPort + defer purge(rsyncResource) + errs := []error{} + for i := 0; i < defaultRetry; i++ { + results := []string{} + var err error + _ = pool.Retry(func() error { + //let rsync server start + time.Sleep(3 * time.Second) + results, err = testutils.RunNucleiTemplateAndGetResults(filePath, finalURL, debug) + return nil + }) + if err != nil { + return err + } + if err := expectResultsCount(results, 1); err == nil { + return nil + } else { + errs = append(errs, err) + } + } + return multierr.Combine(errs...) +} + // purge any given resource if it is not nil func purge(resource *dockertest.Resource) { if resource != nil && pool != nil { @@ -397,4 +431,20 @@ func init() { if err := mysqlResource.Expire(30); err != nil { log.Printf("Could not expire mysql resource: %s", err) } + + // setup a temporary rsync server + rsyncResource, err = pool.RunWithOptions(&dockertest.RunOptions{ + Repository: "alpine", + Tag: "latest", + Cmd: []string{"sh", "-c", "apk add --no-cache rsync shadow && useradd -m rsyncuser && echo 'rsyncuser:mysecret' | chpasswd && echo 'rsyncuser:MySecret123' > /etc/rsyncd.secrets && chmod 600 /etc/rsyncd.secrets && echo -e '[data]\\n path = /data\\n comment = Local Rsync Share\\n read only = false\\n auth users = rsyncuser\\n secrets file = /etc/rsyncd.secrets' > /etc/rsyncd.conf && mkdir -p /data && exec rsync --daemon --no-detach --config=/etc/rsyncd.conf"}, + Platform: "linux/amd64", + }) + if err != nil { + log.Printf("Could not start Rsync resource: %s", err) + return + } + // by default expire after 30 sec + if err := rsyncResource.Expire(30); err != nil { + log.Printf("Could not expire Rsync resource: %s", err) + } } diff --git a/go.mod b/go.mod index c27befc3c..3b6928f21 100644 --- a/go.mod +++ b/go.mod @@ -53,6 +53,7 @@ require ( github.com/DataDog/gostackparse v0.7.0 github.com/Masterminds/semver/v3 v3.2.1 github.com/Mzack9999/gcache v0.0.0-20230410081825-519e28eab057 + github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d github.com/Mzack9999/goja v0.0.0-20250507184235-e46100e9c697 github.com/Mzack9999/goja_nodejs v0.0.0-20250507184139-66bcbf65c883 github.com/alexsnet/go-vnc v0.1.0 @@ -269,6 +270,7 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/k14s/starlark-go v0.0.0-20200720175618-3a5c849cc368 // indirect + github.com/kaiakz/ubuffer v0.0.0-20200803053910-dd1083087166 // indirect github.com/kataras/jwt v0.1.10 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.18.2 // indirect diff --git a/go.sum b/go.sum index 76e4463fe..7db9b0b94 100644 --- a/go.sum +++ b/go.sum @@ -87,6 +87,8 @@ github.com/Mzack9999/gcache v0.0.0-20230410081825-519e28eab057 h1:KFac3SiGbId8ub github.com/Mzack9999/gcache v0.0.0-20230410081825-519e28eab057/go.mod h1:iLB2pivrPICvLOuROKmlqURtFIEsoJZaMidQfCG1+D4= github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809 h1:ZbFL+BDfBqegi+/Ssh7im5+aQfBRx6it+kHnC7jaDU8= github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809/go.mod h1:upgc3Zs45jBDnBT4tVRgRcgm26ABpaP7MoTSdgysca4= +github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d h1:DofPB5AcjTnOU538A/YD86/dfqSNTvQsAXgwagxmpu4= +github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d/go.mod h1:uzdh/m6XQJI7qRvufeBPDa+lj5SVCJO8B9eLxTbtI5U= github.com/Mzack9999/goja v0.0.0-20250507184235-e46100e9c697 h1:54I+OF5vS4a/rxnUrN5J3hi0VEYKcrTlpc8JosDyP+c= github.com/Mzack9999/goja v0.0.0-20250507184235-e46100e9c697/go.mod h1:yNqYRqxYkSROY1J+LX+A0tOSA/6soXQs5m8hZSqYBac= github.com/Mzack9999/goja_nodejs v0.0.0-20250507184139-66bcbf65c883 h1:+Is1AS20q3naP+qJophNpxuvx1daFOx9C0kLIuI0GVk= @@ -634,6 +636,8 @@ github.com/k14s/difflib v0.0.0-20201117154628-0c031775bf57 h1:CwBRArr+BWBopnUJhD github.com/k14s/difflib v0.0.0-20201117154628-0c031775bf57/go.mod h1:B0xN2MiNBGWOWi9CcfAo9LBI8IU4J1utlbOIJCsmKr4= github.com/k14s/starlark-go v0.0.0-20200720175618-3a5c849cc368 h1:4bcRTTSx+LKSxMWibIwzHnDNmaN1x52oEpvnjCy+8vk= github.com/k14s/starlark-go v0.0.0-20200720175618-3a5c849cc368/go.mod h1:lKGj1op99m4GtQISxoD2t+K+WO/q2NzEPKvfXFQfbCA= +github.com/kaiakz/ubuffer v0.0.0-20200803053910-dd1083087166 h1:IAukUBAVLUWBcexOYgkTD/EjMkfnNos7g7LFpyIdHJI= +github.com/kaiakz/ubuffer v0.0.0-20200803053910-dd1083087166/go.mod h1:T4xUEny5PVedYIbkMAKYEBjMyDsOvvP0qK4s324AKA8= github.com/kataras/jwt v0.1.10 h1:GBXOF9RVInDPhCFBiDumRG9Tt27l7ugLeLo8HL5SeKQ= github.com/kataras/jwt v0.1.10/go.mod h1:xkimAtDhU/aGlQqjwvgtg+VyuPwMiyZHaY8LJRh0mYo= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= diff --git a/integration_tests/protocols/javascript/rsync-test.yaml b/integration_tests/protocols/javascript/rsync-test.yaml new file mode 100644 index 000000000..ce4ae4895 --- /dev/null +++ b/integration_tests/protocols/javascript/rsync-test.yaml @@ -0,0 +1,21 @@ +id: rsync-test + +info: + name: Rsync Test + author: pdteam + severity: info + +javascript: + - code: | + const rsync = require('nuclei/rsync'); + rsync.IsRsync(Host, Port); + + args: + Host: "{{Host}}" + Port: "873" + + matchers: + - type: dsl + dsl: + - "success == true" + \ No newline at end of file diff --git a/pkg/js/generated/go/librsync/rsync.go b/pkg/js/generated/go/librsync/rsync.go index 6c269fcb0..ffc6f0a61 100644 --- a/pkg/js/generated/go/librsync/rsync.go +++ b/pkg/js/generated/go/librsync/rsync.go @@ -21,6 +21,7 @@ func init() { // Objects / Classes "IsRsyncResponse": gojs.GetClassConstructor[lib_rsync.IsRsyncResponse](&lib_rsync.IsRsyncResponse{}), + "RsyncClient": gojs.GetClassConstructor[lib_rsync.RsyncClient](&lib_rsync.RsyncClient{}), }, ).Register() } diff --git a/pkg/js/generated/ts/rsync.ts b/pkg/js/generated/ts/rsync.ts index afe214680..6cb675b0d 100755 --- a/pkg/js/generated/ts/rsync.ts +++ b/pkg/js/generated/ts/rsync.ts @@ -13,7 +13,61 @@ export function IsRsync(host: string, port: number): IsRsyncResponse | null { return null; } - +/** + * RsyncClient is a client for RSYNC servers. + * Internally client uses https://github.com/gokrazy/rsync driver. + * @example + * ```javascript + * const rsync = require('nuclei/rsync'); + * const client = new rsync.RsyncClient(); + * ``` + */ +export class RsyncClient { + + // Constructor of RsyncClient + constructor() {} + + /** + * Connect establishes a connection to the rsync server with authentication. + * @example + * ```javascript + * const rsync = require('nuclei/rsync'); + * const client = new rsync.RsyncClient(); + * const connected = client.Connect('acme.com', 873, 'username', 'password', 'backup'); + * ``` + */ + public Connect(host: string, port: number, username: string, password: string, module: string): boolean | null { + return null; + } + + /** + * ListModules lists available modules on the rsync server. + * @example + * ```javascript + * const rsync = require('nuclei/rsync'); + * const client = new rsync.RsyncClient(); + * const modules = client.ListModules('acme.com', 873, 'username', 'password'); + * log(toJSON(modules)); + * ``` + */ + public ListModules(host: string, port: number, username: string, password: string): string[] | null { + return null; + } + + /** + * ListFilesInModule lists files in a specific module on the rsync server. + * @example + * ```javascript + * const rsync = require('nuclei/rsync'); + * const client = new rsync.RsyncClient(); + * const files = client.ListFilesInModule('acme.com', 873, 'username', 'password', 'backup'); + * log(toJSON(files)); + * ``` + */ + public ListFilesInModule(host: string, port: number, username: string, password: string, module: string): string[] | null { + return null; + } +} /** * IsRsyncResponse is the response from the IsRsync function. diff --git a/pkg/js/libs/rsync/rsync.go b/pkg/js/libs/rsync/rsync.go index a1b407395..1dddd8033 100644 --- a/pkg/js/libs/rsync/rsync.go +++ b/pkg/js/libs/rsync/rsync.go @@ -1,18 +1,31 @@ package rsync import ( + "bytes" "context" "fmt" + "log/slog" "net" "strconv" "time" + rsynclib "github.com/Mzack9999/go-rsync/rsync" + "github.com/praetorian-inc/fingerprintx/pkg/plugins" "github.com/praetorian-inc/fingerprintx/pkg/plugins/services/rsync" "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate" ) type ( + // RsyncClient is a client for RSYNC servers. + // Internally client uses https://github.com/gokrazy/rsync driver. + // @example + // ```javascript + // const rsync = require('nuclei/rsync'); + // const client = new rsync.RsyncClient(); + // ``` + RsyncClient struct{} + // IsRsyncResponse is the response from the IsRsync function. // this is returned by IsRsync function. // @example @@ -25,8 +38,30 @@ type ( IsRsync bool Banner string } + + // ListSharesResponse is the response from the ListShares function. + // this is returned by ListShares function. + // @example + // ```javascript + // const rsync = require('nuclei/rsync'); + // const client = new rsync.RsyncClient(); + // const listShares = client.ListShares('acme.com', 873); + // log(toJSON(listShares)); + RsyncListResponse struct { + Modules []string + Files []string + Output string + } ) +func connectWithFastDialer(executionId string, host string, port int) (net.Conn, error) { + dialer := protocolstate.GetDialersWithId(executionId) + if dialer == nil { + return nil, fmt.Errorf("dialers not initialized for %s", executionId) + } + return dialer.Fastdialer.Dial(context.Background(), "tcp", net.JoinHostPort(host, strconv.Itoa(port))) +} + // IsRsync checks if a host is running a Rsync server. // @example // ```javascript @@ -44,11 +79,7 @@ func isRsync(executionId string, host string, port int) (IsRsyncResponse, error) resp := IsRsyncResponse{} timeout := 5 * time.Second - dialer := protocolstate.GetDialersWithId(executionId) - if dialer == nil { - return IsRsyncResponse{}, fmt.Errorf("dialers not initialized for %s", executionId) - } - conn, err := dialer.Fastdialer.Dial(context.TODO(), "tcp", net.JoinHostPort(host, strconv.Itoa(port))) + conn, err := connectWithFastDialer(executionId, host, port) if err != nil { return resp, err } @@ -59,7 +90,7 @@ func isRsync(executionId string, host string, port int) (IsRsyncResponse, error) rsyncPlugin := rsync.RSYNCPlugin{} service, err := rsyncPlugin.Run(conn, timeout, plugins.Target{Host: host}) if err != nil { - return resp, err + return resp, nil } if service == nil { return resp, nil @@ -68,3 +99,115 @@ func isRsync(executionId string, host string, port int) (IsRsyncResponse, error) resp.IsRsync = true return resp, nil } + +// ListModules lists the modules of a Rsync server. +// @example +// ```javascript +// const rsync = require('nuclei/rsync'); +// const client = new rsync.RsyncClient(); +// const listModules = client.ListModules('acme.com', 873, 'username', 'password'); +// log(toJSON(listModules)); +// ``` +func (c *RsyncClient) ListModules(ctx context.Context, host string, port int, username string, password string) (RsyncListResponse, error) { + executionId := ctx.Value("executionId").(string) + return listModules(executionId, host, port, username, password) +} + +// ListShares lists the shares of a Rsync server. +// @example +// ```javascript +// const rsync = require('nuclei/rsync'); +// const client = new rsync.RsyncClient(); +// const listShares = client.ListFilesInModule('acme.com', 873, 'username', 'password', '/'); +// log(toJSON(listShares)); +// ``` +func (c *RsyncClient) ListFilesInModule(ctx context.Context, host string, port int, username string, password string, module string) (RsyncListResponse, error) { + executionId := ctx.Value("executionId").(string) + return listFilesInModule(executionId, host, port, username, password, module) +} + +func listModules(executionId string, host string, port int, username string, password string) (RsyncListResponse, error) { + fastDialer := protocolstate.GetDialersWithId(executionId) + if fastDialer == nil { + return RsyncListResponse{}, fmt.Errorf("dialers not initialized for %s", executionId) + } + + address := net.JoinHostPort(host, strconv.Itoa(port)) + + // Create a bytes buffer for logging + var logBuffer bytes.Buffer + + // Create a custom slog handler that writes to the buffer + logHandler := slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }) + + // Create a logger that writes to our buffer + logger := slog.New(logHandler) + + sr, err := rsynclib.ListModules(address, + rsynclib.WithClientAuth(username, password), + rsynclib.WithLogger(logger), + rsynclib.WithFastDialer(fastDialer.Fastdialer), + ) + if err != nil { + return RsyncListResponse{}, fmt.Errorf("connect failed: %v", err) + } + + result := RsyncListResponse{ + Modules: make([]string, len(sr)), + Output: logBuffer.String(), + } + + for i, item := range sr { + result.Modules[i] = string(item.Name) + } + + return result, nil +} + +func listFilesInModule(executionId string, host string, port int, username string, password string, module string) (RsyncListResponse, error) { + fastDialer := protocolstate.GetDialersWithId(executionId) + if fastDialer == nil { + return RsyncListResponse{}, fmt.Errorf("dialers not initialized for %s", executionId) + } + + address := net.JoinHostPort(host, strconv.Itoa(port)) + + // Create a bytes buffer for logging + var logBuffer bytes.Buffer + + // Create a custom slog handler that writes to the buffer + logHandler := slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }) + + // Create a logger that writes to our buffer + logger := slog.New(logHandler) + + sr, err := rsynclib.SocketClient(nil, address, module, ".", + rsynclib.WithClientAuth(username, password), + rsynclib.WithLogger(logger), + rsynclib.WithFastDialer(fastDialer.Fastdialer), + ) + if err != nil { + return RsyncListResponse{}, fmt.Errorf("connect failed: %v", err) + } + + // Try to list files to test authentication + list, err := sr.List() + if err != nil { + return RsyncListResponse{}, fmt.Errorf("authentication failed: %v", err) + } + + result := RsyncListResponse{ + Files: make([]string, len(list)), + Output: logBuffer.String(), + } + + for i, item := range list { + result.Files[i] = string(item.Path) + } + + return result, nil +}