Skip to content

Reversing Mikrotiks Silent Patch The Routeros 7 23 4 Fix They Wouldnt Explain

npratley.net September 10, 2026

This was AI driven, and verified in a lab, 6 hours vs what would generally take weeks to months of work – why use your hands when you own a shovel or a post hole digger.

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.

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 reproduced code execution. There are three real bugs here, and two conditional chains. One is the low-exponent RSA signature forgery into the mtget overflow. The other—now matched to an active-exploitation support trace—is an SSH username of -2 reaching a legacy file-descriptor login transport, letting an authenticated read-only session supply its own full policy mask. That second path gives full RouterOS command execution and can in turn reach mtget . What I have not reproduced is a stock, credential-free way to make SSH accept literal user -2 in the first place; that boundary matters, and this revision keeps it explicit.

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:

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 release, 20MB each, no auth needed:

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:

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:

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:

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 , followed by an ops user in full . It is a strong behavioural match to this path, not just a shared string IOC.

This client is deliberately restricted to loopback or RFC1918 targets. Its default RouterOS command is read-only. The SSH layer must already accept literal username -2 ; in my lab that acceptance was supplied by the scoped RADIUS responder.

What 7.23.4 fixes—and what remains unknown

7.23.4 validates the stored SSH username before spawning the interactive login child. Literal -2 is rejected with invalid user input ; no trusted fd parser and no RouterOS console follows. This is the direct fix.

The exploit still begins after SSH user authentication. In a clean local-auth lab, -2 was rejected with the correct admin password, an RSA key authorized to admin , numeric/dash aliases -0 through -3 , arbitrary RSA and Ed25519 keys, an attacker-owned e=3 key, and a forged signature for the built-in Go Daddy e=3 CA key. SSH none , keyboard-interactive, embedded-NUL usernames and post-success username mutation also failed. The changed authentication backend is byte-identical. So this work confirms authenticated read-to-full command execution, but it does not manufacture a stock credential-free way for SSH to accept -2 . If the field target did not use RADIUS/User Manager or another external AAA path, a separate initial-access primitive remains missing.

RouterOS 7.23.4 also contains explicit incident remediation in nova/bin/mode : it recognises an ops user in full , suspicious fetch/import scheduler entries and campaign domains, disables known-malicious configuration, logs what it found, and sets device mode to flagged=yes after reboot. That is consistent with the support report and with an incident-response patch, not generic stability work.

Piece two: the RSA signature verifier

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

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:

My 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.

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:

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.

With paramiko==5.0.0 , the results were unambiguous:

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.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:

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:

A patterned run with 542 A s 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 return address is deliberately invalid 0x42424242 , making the completed call visible in the crash record.

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

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.

Two chains, and the boundary that matters

The original RSA-to- mtget chain remains valid for its stated preconditions:

The newly closed incident-shaped path is:

The second chain reproduces the field audit fingerprint exactly, but the bracketed first step is not solved by the clean stock lab. Calling the whole campaign unauthenticated would outrun the evidence. Calling the patched username path mere log hardening would now be equally wrong.

Scope: serious, conditional, and not magic

Confirmed: after SSH accepts literal -2 , a PTY client can replace the trusted policy field and escalate a RouterOS read session to the full 0x9fe6e policy set on 7.23.3.

Confirmed: that session can perform write/policy actions and produces the campaign-shaped user ops added by ssh:-2@... record.

Confirmed: 7.23.4 rejects the same PTY path with invalid user input .

Confirmed: public-key-only SSH authentication works for an account already bound to a known 2048-bit RSA e=3 key on 7.23.3; 7.23.4 rejects the forgery.

Confirmed: authenticated /tool fetch controls EIP in 7.23.3 mtget , and a ROP call executes.

Not confirmed: credential-free SSH authentication as literal -2 on a clean local-user configuration.

Not claimed: a universal RSA e=65537 break, arbitrary-user login, or an end-to-end IKE/TLS bypass.

The validator now has two separately demonstrated effects. On SSH PTYs it closes a trusted policy/argv injection which produces full RouterOS administrative command execution after authentication as -2 . On MAC-Telnet it closes the hidden-API/argv selectors below, whose inner authentication and policy checks still hold. mtget remains the independently patched native-process ROP primitive.

MAC-Telnet: a hidden API tunnel, not unauthenticated RCE

I went back and ground through the MAC-Telnet path separately because mactel is one of the new callers of validLoginParamInput . The result is interesting, but it does not close another credential-free RCE chain.

In 7.23.3 the final length-delimited CP_USERNAME is passed unchanged as the last argument to /nova/bin/login . A leading dash is therefore parsed as a login option. Usernames such as -z , -c and -d , even with a wrong EC-SRP password, divert the expected failure path into a fresh Login: prompt. This is real pre-auth argv injection, but options needing another argv value cannot be completed: the username is the final argument, and CP_TERM_TYPE becomes TERM , not another argument.

The more useful selector is the exact seven-byte username 06 2f 6c 6f 67 69 6e , or b"\x06/login" . The leading byte is a RouterOS API word length. On 7.23.3 this selects the API-style login handler and returns !done =ret= over MAC-Telnet. The identical packet is terminated by 7.23.4.

That last response is the boundary. Supplying the correct password to the inner /login request produced !done , after which /system/identity/print returned =name=CHR . A disposable user with the correct password but a group containing local,read,test,ssh and deliberately omitting api was rejected with std failure: not allowed (9) . Changing the final username after a valid initial EC-SRP exchange also failed, and an all-zero/low-order client public key did not make the confirmation password-independent.

So the impact is a same-broadcast-domain hidden API transport that bypasses whether the IP API service is enabled, firewalled or address-restricted. It does not bypass the RouterOS password database or the account’s api policy. With valid credentials it can carry API commands and could reach the mtget stage if policy permits; without them, I did not obtain a console, API session or command execution. Calling this MAC-Telnet RCE would be wrong.

The 7.23.4 validator matches that result exactly. It rejects empty input, a leading dash or space, a trailing space, control bytes 00..1f , and 7f before opening the PTY. That blocks both the argv forms and x06/login .

Other fixes hiding in the same diff

www adds suspicious skin path , rejects /../ and paths escaping the /rw/disk user-file root with HTTP 403: path-traversal hardening.

SSH/SCP adds destination is an invalid path! around recursive destination processing: authenticated file-path hardening.

diskd adds explicit truncated-request, truncated-read and oversized-response checks around local RPC frames.

DHCP replaces small diagnostic sprintf calls 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.

Patch now: 7.23.4 long-term, 7.24.2 stable, 6.49.21 v6, or a later release containing these fixes.

If /system/device-mode/print shows flagged: yes , treat the router as potentially compromised. Preserve logs/support output, inspect all configuration and strongly consider a clean rebuild rather than trusting only automatic cleanup.

Hunt for an unexpected ops user or full-policy account; audit records containing ssh:-2@ ; scheduler entries combining fetch , /poll/ and import ; the domains mythtime.xyz , leappoach.info and eeongous.com ; and unexpected /ip socks enablement.

Review /user aaa , RADIUS and User Manager configuration. This revision proves what happens after SSH accepts -2 ; external AAA is one concrete way that otherwise-invalid identity can exist.

Restrict SSH to the management plane and rotate administrative passwords, RADIUS secrets and authorized keys after any suspected exposure.

Audit imported SSH RSA keys for exponent 3. Replace legacy low-exponent keys with Ed25519/ECDSA or RSA e=65537 .

Remove test from users that do not need diagnostic tools. RouterOS’s built-in read group 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 unexpected autosupout.rif , mtget restarts and 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. 🐟

Extracted Entities