CVE-2026-89020: How a Read-Only MikroTik RouterOS Account Writes Past a Stack Buffer in /tool fetch
A note on disclosure. This finding was reported to MikroTik and is tracked as CVE-2026-89020 (MikroTik advisory, CVE record).
Introduction
I found CVE-2026-89020 in RouterOS, the operating system that runs on MikroTik routers. It is a stack buffer overflow in the download command /tool fetch: when the command is pointed at a tftp:// URL, the program behind it copies the URL path into a fixed 528-byte buffer on the stack and never checks the path length, so a long enough path writes past the end of that buffer. This post explains how the overflow works and how far the write reaches.
RouterOS has its own account system, and every account belongs to a group. A group is a named list of permissions, which RouterOS calls a policy. The built-in read group is the monitoring role. Its policy lets an account log in and look at the configuration, but it removes the write permission and the policy permission, so the account cannot change settings and cannot edit groups. That is the account you hand to someone who only needs to watch the device. It is also the account this post uses to overwrite the saved registers of the fetch process.
This post is about a command the read group can run that does not respect that boundary. RouterOS has a download command, /tool fetch, and the program behind it is /nova/bin/mtget. When you point it at a tftp:// URL, mtget builds a TFTP read request (an RRQ: the small packet that asks a TFTP server to send a named file, defined in RFC 1350) and copies the URL path into a fixed 528-byte buffer on its stack. The function that does this copy checks two things about the path: that it is not empty, and that it is not exactly /. It never compares the path length against the size of the buffer. It copies the whole path with one block-copy instruction whose length comes straight from the URL. Because the builder wraps the path in fixed framing bytes (the opcode and the leading slash before it, the mode and option fields after it), a path of only 505 bytes, shorter than the 528-byte buffer, already reaches one byte past the end of it. The buffer spans offsets 0 to 527, and the saved registers begin at offset 528, so a longer path overwrites those saved registers.
That by itself is a stack buffer overflow. What makes it matter is where it happens. In the build I examined, RouterOS 7.21.5, mtget is loaded at a fixed address every time (it is not a position-independent executable, or PIE, so there is no load-time address randomization), its stack is marked executable (the processor will run bytes stored on the stack instead of refusing), and it has no stack canary (a random value placed between a buffer and the saved registers, checked just before the function returns, so that an overwrite is caught before the corrupted bytes are used).
Two other researchers, Cristiano Maranci and Davide Turaccio, developed this overflow further into a privilege escalation. In their own work, they used the same bug to raise an authenticated account in the read group to full privileges. That result is theirs. Part 4 describes it, and the References section links to both repositories.
Here are the exact bounds, stated as facts. On RouterOS 7.21.5 x86, from an account in the read group, the copy runs and writes past the buffer, and no TFTP server has to answer for it to happen. Maranci and Turaccio demonstrated the privilege escalation separately, on two MIPS devices running 6.49.11 and 6.49.18. I did not demonstrate native code execution on x86, and the escalation has not been shown on any 7.x build.
Everything static in this post comes from the x86 build of RouterOS 7.21.5 long-term (the main package built on 2026-07-03), whose mtget is 63,380 bytes with SHA-256 8de9aed112e4b3be14c7d54df34a60bee58c798bdaf86107d12bd7403222bcbb. I read the binary with two disassemblers and cross-checked their output, and I ran the live tests in a QEMU/KVM virtual machine with no route to any external network. The fix shipped in RouterOS 7.23.4 long-term, 7.24.2 stable and 6.49.21.
Figure 1. A read-only account reaches an unbounded copy. The request builder is drawn with a dashed outline because the length check that belongs there is missing.
Part 1: From a read-only account to mtget
The read group
RouterOS ships a set of built-in groups. The one to focus on is read. Its policy string, which you can see with /user group print, is:
local,telnet,ssh,reboot,read,test,winbox,password,
web,sniff,sensitive,api,romon,rest-api,!ftp,!write,!policy
A bare word grants a permission. A word with a ! prefix removes it. So this group has the login transports (telnet, ssh, winbox, web, api, rest-api), it can read the configuration (read), and it explicitly does not have ftp, write or policy. The absence of write is the point of the group: a read account is supposed to be unable to change the device.
/tool fetch and output=none
/tool fetch downloads a file over one of several protocols. Its documentation lists the modes ftp, http, https, sftp and tftp, and an output setting that decides what happens to the downloaded bytes. The relevant output values include none (do not store the downloaded data), file (save it to the router’s /file store) and user (store it in a data variable).
At first sight fetch looks like it needs the ftp permission, which the read group does not have. That permission gates only the dst-path= option, which is what writes a downloaded file into /file. It does not gate the command itself. With output=none, fetch stores nothing, so the dst-path write never happens, and the ftp permission is never required. An account in the read group can run the command, and its tftp:// form reaches mtget’s request builder. The command that does it has this shape:
/tool fetch url=tftp://192.0.2.1/<507 or more path bytes> output=none
The address 192.0.2.1 is a documentation address (RFC 5737); it stands in for any host. The angle-bracket placeholder stands in for a long path. As Part 3 shows, the host does not even need to exist.
The TFTP read request
A TFTP read request is a short packet. RFC 1350 section 5 defines it as a two-byte opcode (the value 1 for a read request), then the file name as text, then a zero byte, then the transfer mode as text, then a zero byte. RFC 2347 adds options after that, each written as an option name, a zero byte, a value and a zero byte. RFC 2348 defines one such option, blksize, which asks for a larger block size.
mtget builds exactly this. For a tftp:// fetch it writes the opcode, then the path from the URL, then the mode octet, then the blksize option with the value 4096. One detail decides everything later: the file name it writes is the URL path with its leading slash kept, so a URL path of P characters after the slash becomes a file name of P plus 1 bytes in the packet. The fields and their sizes, in octet mode, are:
| Field | Content | Bytes |
|---|---|---|
| opcode | 00 01 (read request) | 2 |
| file name | / followed by the URL path | 1 + P |
| separator | 00 | 1 |
| mode | octet | 5 |
| separator | 00 | 1 |
| option name | blksize | 7 |
| separator | 00 | 1 |
| option value | 4096 | 4 |
| separator | 00 | 1 |
P is the number of path bytes after the leading slash. The whole packet in octet mode is therefore P plus 23 bytes long. The builder writes one more zero byte after the last field, one byte beyond the length it will actually send. Those are the pieces Part 2 counts.
Part 2: The request builder
How the builder is reached
A tftp:// fetch arrives in a driver that matches the URL scheme against a short list. The scheme string tftp is loaded as the comparand at 0x08053d52 (mov edx,0x805599b) and compared by the string-helper call at 0x08053d5d. On a match the driver allocates a connection object with malloc(0x140) at 0x08053d6e and runs its constructor at 0x0804d424. The constructor move-constructs the parsed URL path into a string member of that object at offset +0x30 (the moves at 0x0804d4cd to 0x0804d4d1). A RouterOS string is a pointer to a small heap block that holds a 32-bit length at offset 0 and the characters starting at offset 4. The stored path keeps its leading slash.
The builder itself is a C++ virtual method. A virtual method is one the program picks at run time through a vtable, which is a table of function pointers attached to an object. This object’s vtable is at 0x08055fe4, the builder’s pointer sits in the slot at +0xc (stored at 0x08055ff0), and the driver reads the vtable pointer from the object’s first word at 0x08053eba (mov eax,[ebx]) and calls the builder slot at 0x08053ebd with call DWORD PTR [eax+0xc]. The method that runs is the function at 0x080508ae. It reads the stored path out of the connection object and assembles the packet.
The builder is one of several methods on this connection object. Other methods handle the server side of the TFTP exchange, once a request has been sent and the server answers, including an adjacent handler that parses the server’s reply. The read-group attack never reaches that server-facing code, because the overflow happens while the request is still being built, before mtget sends anything. The incomplete path validation is also not unique to this builder. A sibling method in the same object, the TCP-connect path at 0x0804fda8, reads the same stored path from [ebx+0x30] and applies the same two guards, the non-empty check and the string::compare against / through the same helper at 0x0804d218, with no length check either. The shared pattern is the weak validation of the path. The unbounded copy that overflows is specific to the TFTP read-request builder.
The copy
The function begins by setting up its frame and reserving the buffer:
80508ae: push ebp
80508af: mov ebp, esp
80508b1: push edi
80508b2: push esi
80508b3: push ebx
80508b4: sub esp, 0x22c
The buffer for the outgoing packet starts at ebp-0x21c. Before the copy, the only two tests the function makes on the path are a check that it is not empty (cmp DWORD PTR [eax],0x0 at 0x080508c0) and a check that it is not exactly / (a string::compare against the one-character string, through the helper at 0x0804d218, called at 0x080508df). Both tests only decide whether to reject the request with the message no remote path specified!. Neither compares the length of the path with the size of the buffer. Nothing in the function does.
Here is the copy:
8050a0e: mov esi, DWORD PTR [ebx+0x30]
8050a11: mov WORD PTR [ebp-0x21c], 0x100
8050a1a: lea edx, [ebp-0x21a]
8050a20: add esi, 0x4
8050a23: mov eax, DWORD PTR [esi-0x4]
8050a26: mov edi, edx
8050a28: mov ecx, eax
8050a2a: mov edx, 0x80555de
8050a2f: rep movs BYTE PTR es:[edi], BYTE PTR ds:[esi]
8050a31: cmp BYTE PTR [ebx+0x65], 0x0
8050a35: mov BYTE PTR [ebp+eax*1-0x21a], 0x0
Line 1: loads the stored path’s string pointer from the connection object at [ebx+0x30].
Line 2: writes the two-byte opcode at the buffer base. The value 0x100 stored little-endian puts the bytes 00 01 at the front, which is the TFTP read-request opcode.
Line 3: sets the copy destination to ebp-0x21a, which is the buffer base plus 2, the position right after the opcode.
Lines 4 and 5: step the source pointer past the string’s 4-byte length header to the characters, and load that 32-bit length into eax. This is the stored length from the string header, not the result of a strlen scan, so the full stored path is taken regardless of its contents.
Lines 6 to 8: set up the move: edi is the destination, ecx is the byte count, and ecx is just eax, the stored length. Line 8 loads the netascii mode string into edx as a starting value, which the mode flag can replace.
Line 9: is the copy. rep movs moves ecx bytes from the source to the destination. ecx is the path’s own length, and nothing has clamped it, so a path of any size is copied in full into the 528-byte buffer.
Line 10: reads the mode flag on the object ([ebx+0x65]); a zero byte there, the default fetch, selects octet in place of the netascii from line 8.
Line 11: writes the terminating zero after the copied path, at the destination plus eax. Because eax is the attacker’s length, this terminator also moves with the path.
The bytes this copy moves are the attacker’s own. The URL path reaches it without transformation: mtget does not percent-decode the path (there is no comparison anywhere in the builder against the byte 0x25, the character %), and I confirmed on the wire that the path is sent exactly as it was typed. So every printable byte of the path is written to the stack byte for byte, and the bytes that land on the saved registers are the ones the attacker chose, not a decoded or escaped form of them. The one limit is that the path cannot contain a zero byte, because it is carried in the fetch command string, which ends at its first zero.
The frame
The buffer sits directly below the saved registers, with nothing between them. The 528-byte request buffer occupies the bytes at offsets 0 to 527, counting from the buffer base at ebp-0x21c. Immediately above it are the registers the function saved when it started, each at a fixed offset from that base: saved ebx at offset 528, saved esi at 532, saved edi at 536, saved ebp at 540, and the saved return address at 544 (the slot at ebp+4). There is no canary between the buffer and these saved slots, and no other local variable sits there either: the function’s other locals are placed below the buffer, not above it. So the first byte written past offset 527 lands in saved ebx, and a longer write walks up through esi, edi and ebp into the saved return address.
The saved return address matters because of how the function ends. Its last instructions are:
8050b56: lea esp, [ebp-0xc]
8050b59: pop ebx
8050b5a: pop esi
8050b5b: pop edi
8050b5c: pop ebp
8050b5d: ret
The lea esp,[ebp-0xc] points the stack pointer at the saved-ebx slot at offset 528. The four pop instructions then reload ebx, esi, edi and ebp out of the same slots the overflow writes into. The ret at 0x08050b5d reads the four bytes at offset 544 into EIP, the instruction pointer that holds the address of the next instruction to run, and transfers execution there. A path long enough to reach offset 544 therefore decides where this ret goes.
This is the structural mechanism, not a demonstrated redirection. Controlling that value to run attacker-chosen native code on x86 was not demonstrated: I observed no case of EIP holding an attacker value, and no public x86 exploit exists. What the disassembly shows is that the overwrite reaches the saved return address and that the function consumes it with a ret.
Figure 2. The stack frame of the request builder in 7.21.5. The 528-byte buffer runs straight into the saved registers at offsets 528 to 540 and the saved return address at 544, with no canary in between. These offsets are the same on every run, and the function’s ret loads whatever sits at 544 into EIP.
How long is too long
After the path, the builder keeps writing. It appends a zero byte, then the mode string, then a zero byte, then blksize, then a zero byte, then the value 4096, then a zero byte. The 4096 is a fixed constant: it is produced by sprintf with the format %d and the literal 0x1000 pushed as the argument at 0x08050a8a, so an attacker cannot change its length. After the last field the builder writes one more zero byte at 0x08050abf, one byte past the length it then sends with sendto at 0x08050af2.
Counting those pieces gives the total write. In octet mode the packet is 2 (opcode) plus 1 plus P (slash and path) plus 1 (separator) plus 5 (octet) plus 1 plus 7 (blksize) plus 1 plus 4 (4096) plus 1, which is P plus 23 bytes, and the extra trailing zero lands at offset P plus 23. The highest offset the builder touches is therefore P plus 23. It first reaches offset 528, the first byte past the buffer, when P is 505. At P equal to 504 the highest offset written is 527, the last byte still inside the buffer.
| Mode | Mode string | Packet bytes | Highest offset written | First byte past the buffer |
|---|---|---|---|---|
| octet | octet (5 bytes) | P + 23 | P + 23 | P = 505 |
| netascii | netascii (8 bytes) | P + 26 | P + 26 | P = 502 |
Netascii mode uses a mode string three bytes longer, so its packet is P plus 26 bytes and it crosses offset 528 three bytes sooner, at a path of 502. The default fetch uses octet, so 505 is the figure used in the rest of this post.
The same arithmetic says how far a longer path reaches, and it separates two different things. The highest offset is always P plus 23, so each saved slot is first touched at a known path length: offset 528, the low byte of saved ebx, at P equal to 505; saved esi and edi, at 532 and 536, at P equal to 509 and 513; saved ebp, at 540, at P equal to 517; and the saved return address, at 544 to 547, first at P equal to 521 and fully at 524. At those lengths, though, the bytes that land on the return address are the packet’s trailing framing bytes, the zeros and the option text that follow the path, not content the attacker chose. The attacker’s own path bytes reach offset 544 only at P equal to 542, and cover all four return-address bytes at P equal to 545. So there are two thresholds. Corrupting the saved return address with framing bytes begins around P equal to 521, and controlling its value with attacker-chosen bytes begins at P equal to 542. This is the frame arithmetic only. It names no return-address value, no target and no gadget, and native code execution on x86 was not demonstrated.
Part 3: Measuring the overwrite on 7.21.5
The arithmetic in Part 2 predicts that the write first leaves the buffer at a 505-byte path. I checked that on a live system by increasing the path length one byte at a time and watching mtget’s behavior change. The build is the same 7.21.5 x86 image described in the introduction, running in an isolated VM with no route to any outside network.
| URL path bytes | What mtget did |
|---|---|
| 498 to 504 | the request is built and sent, and the exchange ends normally with the client message failure: peer sent error packet, in 0.3 to 1.3 seconds of SSH command time |
| 505 to 506 | the control flow is altered: a partial status line, a no such item (4), and a 10 to 12 second hang |
| 507 or more | mtget stops running, and the fetch command waits out its full timeout, 60.4 seconds measured three times |
The 60.4 second wait is the RouterOS fetch timeout; shorter stop times in my logs are my test harness timing out the SSH command, not RouterOS behavior. The change at 505 is exactly where the builder first writes above the buffer, the first byte past its end. Below 505 the whole packet fits inside the 528-byte buffer and the program behaves normally. At 505 and 506 the overwrite begins, but the first bytes to land past the buffer are the packet’s trailing framing zeros, not content the attacker chose. The first saved slot is saved ebx at offset 528, which holds the pointer to the TFTP connection object, and at these two lengths only its low bytes are overwritten. The epilogue then restores a connection-object pointer whose low byte has changed, and after the builder returns the surrounding code works on a slightly wrong pointer. That is why the exchange diverges only slightly here, into the partial status line and the no such item (4). Attacker-chosen path bytes do not reach the saved-register region until the path is longer, offset 528 at a path of 526 and the return address at 542. From 507 on, more of the saved registers is overwritten and mtget does not return cleanly at all.
The read group reaches this. Running /tool fetch url=tftp://<host>/<520 A's> output=none as a stock read-group account produced the exact same result as any other account. The command returned the client message action timed out - try again, if error continues contact MikroTik support and send a supout file (13), and the router recorded the overflow in two places.
First, it wrote an autosupout.rif file to its /file store. This is the automatic form of RouterOS’s supout.rif support archive, the same diagnostic file an operator generates by hand to send to MikroTik support, produced here on its own because a service malfunctioned. Six such dumps across my runs measured 454.1, 456.0, 460.6, 461.7, 476.6 and 546.1 KiB. Its sections are opaque base64 blocks meant for MikroTik, with no CPU registers or backtrace I could read, which is one concrete reason I could measure how far the overwrite reached but could not confirm control of EIP. Second, it logged system,error,critical Automatic supout.rif file generated due to service malfunction. Those two artifacts are the visible trace that the copy ran past the saved registers and mtget stopped running. They are evidence of how far the write reaches, and I am treating them only as that.
Two further observations matter. The target host does not need to exist: pointed at a silent, nonexistent address, the same command produced the same result (a 456 KiB dump and the critical log entry), because the overflow happens while the packet is being built, before anything is sent.
The crash also stays inside one process. mtget is one of the nova subsystem programs under /nova/bin; the console drives it over RouterOS’s internal message bus (mtget links the message-bus library libumsg.so), and the /tool fetch command is dispatched to it, so it runs as its own process. That is why its crash does not take the device down. Through every run the device kept running: its uptime kept climbing with no reboot, and ssh, ftp, www, winbox and api all stayed listening. Only the one fetch worker was affected. I did not instrument how mtget is spawned or what privilege it runs at, and I make no claim about its user id.
The fetch subsystem also recovered on its own. Benign fetches issued immediately after a crash completed normally: three right after the first crash, in 0.41, 1.31 and 0.31 seconds, one more after two repeat crashes, in 0.3 seconds, and an unrelated http fetch, in 0.41 seconds. So the footprint of one trigger is bounded, a single wedged CLI command for about 60 seconds plus a roughly 450 to 550 KiB dump written to flash. The write is contained inside mtget’s own process, which is the process the next part is about.
Part 4: From an overwrite to privilege escalation
What would stop it, and what mtget has
Four standard defenses would each have made it harder to turn this overflow into more than a corrupted process. The first three are the ones named in the introduction.
- A stack canary would have sat between the buffer and the saved registers, and the overwrite would have been caught before the function returned.
- PIE would have loaded the program at a different base on each run, so the fixed addresses this binary uses would not be known ahead of time.
- A non-executable stack, enforced by the NX (no-execute) memory permission, would stop the processor from running any bytes that an overflow leaves on the stack.
- Full RELRO with BIND_NOW would make the table of resolved function pointers (the GOT, the Global Offset Table) read-only once the program has loaded, so a later call could not be redirected by writing to it. Partial RELRO, which is what mtget has, leaves that table writable.
Here is what mtget has in the build with the bug and in the two later builds I compared:
| Property | 7.21.5 | 7.23.3 | 7.23.4 |
|---|---|---|---|
| load address | fixed (not PIE) | fixed | fixed |
| stack | executable (RWE) | non-executable (RW) | non-executable (RW) |
| stack canary | none | none | none |
| RELRO | partial (GOT writable) | partial | partial |
On 7.21.5 mtget has the fixed load address, the executable stack and no canary all at once. 7.23.3 is the 7.23 release before the fix. Its stack is non-executable, where 7.21.5’s was executable, but it still has no canary and still keeps a writable GOT, and its request builder is the same vulnerable code as 7.21.5. 7.23.4 is the first build with the overflow itself fixed, which Part 5 covers.
These properties are not just present or absent; each one decides how much a linear stack overwrite like this could be turned into something worse, and on 7.21.5 each of these properties favors the attacker. No stack canary means the write reaches the saved return address with nothing to catch it, and the function runs to its ret with the overwritten address still in place. The fixed, non-PIE base means every address in the binary is the same on every device and every run, known ahead of time without a separate information leak. The executable stack means bytes the overflow itself leaves on the stack can be run as instructions, so an attacker would not need suitable code to be present in the process already. Taken together, that is the most favorable case an attacker could meet for turning this overwrite into native code execution.
On 7.23.3 and 7.23.4 one of the three differs: the stack is non-executable. The overflow can still overwrite the saved return address, but bytes left on the stack can no longer be run as instructions, which removes the simplest route and raises the difficulty. The other two still help an attacker: there is still no canary to catch the overwrite before the ret, and RELRO is still only partial, so the table of resolved function pointers stays writable after the program has loaded. None of this is a demonstration. Native code execution on x86 was not demonstrated on any of these builds; this is only what each property would mean for an attacker who tried.
This is not confined to mtget. I surveyed every program in the 7.21.5 main package’s /nova/bin: 78 ELF programs, all of them of type ET_EXEC (loaded at a fixed base), all 78 with an executable stack, not one with a stack-protector symbol, not one with BIND_NOW. In 7.23.3 and 7.23.4 the same 78 programs are still all ET_EXEC and now all have a non-executable stack, but of the 78 only /nova/bin/cloud has a stack canary. A memory-safety bug in any of these programs lands in the same unprotected environment.
The same measurement over the whole image shows the same pattern. Across all 155 userland x86 ELF objects in the 7.21.5 image, not one has full RELRO, so every GOT is writable; a stack canary is present in exactly one object, the smartctl utility, so almost nothing carries one; none were built with FORTIFY_SOURCE; and the large majority, 150 of the 155, including every /nova/bin binary across every package, run on an executable stack. These are my own measurements across the image. A memory-safety bug almost anywhere in this userland therefore sits in a predictable address space with a writable GOT, an executable stack, and no canary to catch it.
The published escalation
Cristiano Maranci and Davide Turaccio developed this overflow into a privilege escalation. Their strategy follows from how RouterOS stores permissions: a group’s rights are defined by its policy string, so rewriting the stored policy of the group an account belongs to changes that account’s rights directly. They use the overflow to rewrite the read group’s own stored policy, so that, as they put it, “the read group becomes the full group”: the write and policy permissions the read group is defined to withhold become granted, and an authenticated account in that group ends up with full privileges. They demonstrated it on two MIPS big-endian devices, a RouterBOARD SXT-5D running 6.49.11 (a live device) and an RB433GL running 6.49.18. Turning a linear stack overwrite into a working escalation from the read group to full privileges, and confirming it on a live device, is substantial work, and it goes well beyond the process crash this post measures. The escalation is their work.
Their result does not carry over to the x86 build unchanged, which is part of why the escalation has not been shown on any 7.x build. It was built for and demonstrated on MIPS big-endian 6.49.x, and three things are different on x86 7.21.5. The architecture and calling convention are different: on MIPS the return address is held in a register, $ra, not read off the stack by a ret as it is on x86, so the final step of the overwrite is not the same step. The byte order is opposite, MIPS big-endian against x86 little-endian, which changes the byte layout of any word the overflow lands on. And the memory and stored-configuration layout is specific to each build: even between the two x86 builds I examined the connection-object field offsets move, with the path member shifting from [ebx+0x30] to [ebx+0x2c], and the researchers note that their public script works only against one particular 6.49.18 image. A result proven on MIPS 6.49.x is a separate result from the x86 overwrite I demonstrated here.
MikroTik’s own advisory, dated 6 October 2026, confirms that the overflow is reachable from a low-privilege account, stating that “even a read-only user can trigger it”. It adds that memory corruption of this type “can sometimes be abused for more than a crash”, which is why it recommends upgrading rather than relying on limited user rights to contain the problem.
Why the read-only boundary does not stop the overflow
The read group’s limits are a property of how RouterOS handles the commands a user sends. When an account types a command, RouterOS checks that command against the account’s policy, and a read account’s write and policy permissions are absent, so the command handler refuses to change settings or edit groups. This is the reasoning that follows from the finding, and I am marking it as reasoning rather than as something I instrumented.
The native code inside mtget is a different layer. It is machine code already executing inside the fetch process, and it is not checked against the policy of the account that started the fetch. The policy check happens when RouterOS accepts the command; it does not reach inside the program the command launched. Maranci and Turaccio’s escalation is a direct demonstration of this gap: it shows code running inside mtget changing stored configuration, specifically the read group’s own policy, which the account could not change through any command. So the boundary between read and write is enforced at the command layer, and an overflow that runs its own writes inside mtget acts below that layer, where the check does not apply.
Part 5: The fix in 7.23.4
The change
The release notes for 7.23.4, 7.24.2 and 6.49.21, all three released on 2026-09-03, each contain one line: fetch - improve stability of the TFTP client;. MikroTik does not map that line to the CVE, but it is the change. The 7.23.4 request builder, now at 0x0804eeec, reserves a slightly larger frame (sub esp,0x23c) and assembles the packet in a completely different way from 7.21.5 and 7.23.3.
Two new variables control how the packet is assembled. A byte budget is set with mov DWORD PTR [ebp-0x228],0x204 at 0x0804f06e, which is 516. A write cursor at [ebp-0x22c] starts at the buffer base. Every piece of the packet (the opcode, the path, the mode, blksize with its zero byte) is now appended through a new helper at 0x0804d2df, and that helper checks the budget before it copies anything:
804d2df: push ebp
804d2e0: mov ebp, esp
804d2e2: push edi
804d2e3: push esi
804d2e4: push ebx
804d2e5: mov ebx, eax
804d2e7: mov eax, DWORD PTR [ebp+0x8]
804d2ea: cmp DWORD PTR [edx], eax
804d2ec: jb 804d2fe
804d2ee: mov edi, DWORD PTR [ebx]
804d2f0: mov esi, ecx
804d2f2: mov ecx, eax
804d2f4: rep movs BYTE PTR es:[edi], BYTE PTR ds:[esi]
804d2f6: add DWORD PTR [ebx], eax
804d2f8: sub DWORD PTR [edx], eax
804d2fa: mov al, 0x1
804d2fc: jmp 804d300
804d2fe: xor eax, eax
804d300: pop ebx
804d301: pop esi
804d302: pop edi
804d303: pop ebp
804d304: ret
Line 7: loads the length of this piece from the stack argument into eax.
Lines 8 and 9: are the check that was missing in 7.21.5. edx points at the remaining budget. cmp DWORD PTR [edx],eax compares the budget against the length, and jb 804d2fe jumps to the failure path when the budget is below the length. A piece that does not fit is never copied.
Lines 10 to 12: set up the move: edi is the destination, esi is the source, and ecx is the byte count.
Line 13: is the copy. rep movs copies the piece to the cursor, which [ebx] holds.
Lines 14 and 15: advance the cursor by the length and reduce the budget by the same amount, so the next piece is measured against what is left.
Lines 16 and 17: return 1 for success, while line 18 is the failure path, which returns 0 and copies nothing.
The value 4096 is now written with snprintf (a new import in this build) instead of sprintf, with the budget passed as its size limit and its result checked. Any piece that fails the budget check makes the builder jump to 0x0804f1da, which sets the fetch error to the new string Filename too long (at 0x080553ce) and returns without ever calling sendto. The longest path the builder will now accept in octet mode is 494 bytes including the leading slash, so 493 after it, and 490 in netascii mode. The budget runs out at offset 515, which is below the saved registers at offset 528. The over-long path is refused before a single byte crosses the buffer.
What did not change
The fix is a change to this one builder, not to the binary’s defenses. Between 7.23.3 and 7.23.4 no ELF hardening property of mtget changed: it is still ET_EXEC, still has no canary, still has partial RELRO. The only differences are in the code itself, where .text grew by 240 bytes and .rodata by 16. And the earlier 7.23.3 build, whose builder is at 0x0804eeb6, still carries the original unbounded copy (the path member is at [ebx+0x2c] there and the rep movsb is at 0x0804f037), so a router on 7.23.3 is still exposed. The fix is in 7.23.4, not earlier in the 7.23 series.
This is a localized change, and it is verifiable as one. In 7.23.4 the builder no longer copies into the buffer itself: every piece of the packet goes through the bounded helper, so the one byte-copy loop in the request path now sits inside that helper, behind the budget check. The receive-side sibling methods are not touched by the fix. I am not claiming a separate bug in them; they are simply outside this finding.
The same fix in 7.24.2 and 6.49.21
The release notes tie the same change to two other branches, 7.24.2 stable and 6.49.21. I confirmed both in the binaries, on x86.
In 7.24.2 the builder is the same code as 7.23.4: the same entry at 0x0804eeec, the same bounded append helper at 0x0804d2df (byte for byte identical), the same 516-byte budget at the same frame slot, and the same sub esp,0x23c frame. A normalized comparison of the builder lines up one to one with 7.23.4. The mtget binary is only about 4 KB larger, from unrelated changes elsewhere.
In 6.49.21 the fix is the same idea built into the older 6.x code. The builder and the helper are at different addresses, 0x0804dba0 and 0x0804f424, and use different registers, but the helper checks the same 516-byte budget before it copies each piece and refuses a piece that does not fit, so an over-long path never leaves the buffer. Here the frame was not enlarged: its sub esp,0x22c is the same frame size as the vulnerable 7.21.5 build, so in 6.49.21 too the fix is the added budget and bounded helper, not a bigger buffer. Its base posture is weaker than the 7.x builds, with no RELRO at all, but the overflow is closed.
These confirmations are all on x86 binaries: the 7.24.2 mtget with SHA-256 9ab9ac52feee32c1a37c850dafb2700829abff6b9667298151432ace1202c501 and the 6.49.21 mtget with SHA-256 43c3d1471780ecc1dd99cae0a6e9b3a68cf86170ad2a04241bf112e482df0c2a. I did not analyze a MIPS binary; the MIPS builds remain Maranci and Turaccio’s separate work.
| Build | Builder | Bounded helper | Budget | Frame | RELRO |
|---|---|---|---|---|---|
| 7.23.4 | 0x0804eeec | 0x0804d2df | 516 (0x204) | sub esp,0x23c | partial |
| 7.24.2 | 0x0804eeec | 0x0804d2df | 516 (0x204) | sub esp,0x23c | partial |
| 6.49.21 | 0x0804dba0 | 0x0804f424 | 516 (0x204) | sub esp,0x22c | none |
Part 6: Detection and response
The direct response is to upgrade to the fixed build on your release line. The overflow is present across three release lines, and the fix lands at a specific build on each. RouterOS 6.0.0 up to (but not including) 6.49.21 is affected, 7.0.0 up to (but not including) 7.23.4 is affected, and 7.24.0 up to (but not including) 7.24.2 is affected. The fix is in 6.49.21, 7.23.4 long-term and 7.24.2 stable, and in later builds on the same line. A higher version number on a different line does not mean the fix is present: a router on 7.24.1 is still vulnerable, even though 7.24.1 is numerically above the fixed 7.23.4, because the fix reaches the 7.24 line only at 7.24.2. On an unpatched router, treat every account in the read group as if it were an administrator account, because Maranci and Turaccio’s escalation shows that an account in that group can reach full privileges.
A crash leaves a visible trace. The critical log line system,error,critical Automatic supout.rif file generated due to service malfunction, together with a fresh autosupout.rif in /file shortly after a /tool fetch, is the signature of the overflow reaching past the saved registers. The .rif itself is an opaque bundle of base64 support sections, so it marks that mtget crashed but does not record what the overflow did. A monitoring account running fetch against an odd URL is itself worth noticing. Absence of that trace is not a guarantee of safety, because an overflow aimed at a working escalation rather than a crash need not crash the process.
One step is specific to this bug and I am giving it as reasoning. Maranci and Turaccio’s escalation changes stored configuration, specifically a group’s policy, and a firmware upgrade keeps the existing configuration. So upgrading closes the overflow but does not undo any change an attacker already made. After upgrading, compare the read group’s policy against the default and confirm it still reads local,telnet,ssh,reboot,read,test,winbox,password,web,sniff,sensitive,api,romon,rest-api,!ftp,!write,!policy, with !write and !policy present. A read group that now carries write or policy is a sign the group was already modified.
MikroTik’s advisory gives the interim measures to apply while planning the upgrade: review the accounts on the device, remove any that are not needed, and keep the management services unreachable from untrusted networks.
References
- CVE-2026-89020 record (CVE.org)
- MikroTik security advisory for CVE-2026-89020
- MikroTik forum: 7.23.4 long-term release notes
- MikroTik forum: 7.24.2 stable release notes
- MikroTik forum: 6.49.21 long-term release notes
- Maranci and Turaccio, privilege-escalation proof of concept (GitHub)
- Maranci and Turaccio, privilege-escalation research (GitLab)
- MikroTik documentation: Fetch
- MikroTik documentation: User
- RFC 1350, The TFTP Protocol (Revision 2)
- RFC 2347, TFTP Option Extension
- RFC 2348, TFTP Blocksize Option