Striga
← Back to research1994 Called. It wants its shell back

Striga reproduced and weaponized a 32-year-old telnet buffer overflow.

gyaraDOS

Overview

A buffer overflow bug in GNU inetutils telnetd was sitting undetected since 1994. It requires no authentication to trigger, just a TCP connection. On 32-bit systems, the memory layout and byte constraints create a viable path toward code execution, though it requires a carefully controlled environment. On 64-bit systems, the triplet format makes traditional GOT overwrite mathematically impossible, but a hybrid approach using /proc/PID/mem still demonstrates RCE in a lab setting.

Striga analyzed the binary, mapped out the byte constraints, and produced a GOT overwrite proof-of-concept for 32-bit targets. On 64-bit systems the triplet format makes traditional GOT overwrite mathematically impossible - Striga's analysis confirmed this, then produced a hybrid proof-of-concept using a different write primitive. This article covers the technique, the math, and where each approach hits its limits.

Background: What is telnet, and why does it still exist

Telnet is a network protocol for remote command-line access, predating SSH by about two decades. It has one well-known problem: everything goes over the wire in plaintext, including passwords. SSH replaced it for most purposes in the late 1990s.

And yet, Telnet is still running in production on embedded devices, industrial control systems, network equipment from Juniper, Cisco, Brocade, Citrix NetScaler, and others. The reason is usually the same: the device is too old, too expensive to replace, or the vendor never shipped an SSH option. The Telnet daemon running on that hardware is often a port of the same codebase that has been copied from system to system since the BSD era.

How the telnet protocol actually works

Telnet does not just send raw bytes across the network. It has a negotiation layer built into the connection setup, where client and server agree on capabilities before any login prompt appears. This negotiation uses a special byte 0xFF, called IAC (Interpret As Command), to signal that what follows is a protocol command rather than user data.

One of such capabilities is LINEMODE. In the default character mode, every key is sent across the network immediately. LINEMODE changes this: the client buffers a full line locally and sends it only when the user presses Enter. This reduces packet counts and was especially useful on slow or per-packet-billed links in the 1990s.

But for LINEMODE to work, the client needs to know how to handle special keys locally - things like Backspace, Ctrl+C, Ctrl+Z. The server and the client negotiate this through SLC (Set Linemode Characters). SLC is a list of 3-byte entries, called triplets, each describing one special character:

  • func - which function this is (1 to 18, corresponding to things like interrupt, erase character, end of file)
  • flag - what level of support the client has (can change it, cannot change it, not supported)
  • val - the byte value that triggers this function

This exchange happens before any login. The server sends its preferred values, the client can propose changes, and the server acknowledges. Both sides end up with the same mapping.

The report that started it

Dream Security vulnerability report

CVE-2026-32746 was found and reported by Adiel Sol from Dream Security, an Israeli cybersecurity company. Dream reported the bug to GNU inetutils maintainers on March 11, 2026, describing exactly how add_slc writes past the end of slcbuf without any bounds check, and how the corrupted slcptr pointer that follows in BSS gives an attacker an arbitrary write primitive. The report was thorough on the vulnerability side - pre-auth, no credentials required, triggers during option negotiation before any login prompt, affects all versions up to 2.7. Dream also included a short video in the mailing list submission demonstrating the overflow in a lab environment, showing a connection to a vulnerable container and id returning uid=0(root). Looking at the addresses in the demo - slcbuf at 0x10000, GOT target at 0x41a2a0, shellcode at 0x40a500 - it is clearly a purpose-built binary with a custom memory layout and hardcoded addresses. That is the right approach for a disclosure PoC: prove the bug is exploitable without handing anyone a ready-to-use weapon against real systems.

What Dream's report did not cover was the full exploitation path on realistic targets - how to work within the triplet byte constraints, how to handle the alignment problem, what the 64-bit situation looks like, and whether the same technique carries over across architectures. That is where Striga comes in. Given the source code and the binary, Striga analyzed the memory layout, worked out the byte control math, and produced exploit code demonstrating GOT hijack on 32-bit targets and a hybrid proof-of-concept for 64-bit systems. The rest of this article covers how those exploits work and why the gap between the two architectures exists.

Vulnerability

The bug is in add_slc, the function that writes SLC triplets into a global buffer called slcbuf.

static unsigned char *slcptr; /* pointer into slc buffer */
static unsigned char slcbuf[NSLC * 6]; /* buffer for slc negotiation */
 
// [...]
 
/*  
* add_slc  
*  
* Add an slc triplet to the slc buffer.  
*/  
void  
add_slc (char func, char flag, cc_t val)  
{  
  
 if ((*slcptr++ = (unsigned char) func) == 0xff)  
   *slcptr++ = 0xff;  
  
 if ((*slcptr++ = (unsigned char) flag) == 0xff)  
   *slcptr++ = 0xff;  
  
 if ((*slcptr++ = (unsigned char) val) == 0xff)  
   *slcptr++ = 0xff;  
  
}

NSLC is 18, the number of defined SLC functions. The buffer is sized for the maximum possible server response: 18 entries times 6 bytes each (worst case when every byte is 0xFF and gets doubled). There is no check on how many triplets actually get written.

The patched version adds a single guard at the top:

/* Do nothing if the entire triplet cannot fit in the buffer.  */  
 if (slcbuf + sizeof slcbuf - slcptr <= 6)  
   return;

That's the entire fix. One bounds check. The bug was introduced in 1994, the same code was copied into virtually every Telnet implementation across BSD, Linux distributions, and commercial network equipment, and nobody caught it for 32 years.

The 0xFF doubling in add_slc is a protocol requirement: since 0xFF is the IAC byte and has special meaning in Telnet, any literal 0xFF value in data must be escaped by sending it twice. This means a triplet that contains 0xFF bytes will occupy 4, 5, or 6 bytes in the buffer instead of 3. This matters for exploitation later.

Defer mechanism

Normally, SLC triplets from the client are processed immediately when received. But there is a condition: the server will only process them if the terminal has already been initialized (terminit() returns true), which happens after the client sends its terminal type (TTYPE).

If SLC arrives before TTYPE, the server saves the raw packet to a heap-allocated buffer (def_slcbuf) and processes it later, once terminit() is satisfied. This deferred path is how legitimate clients that send SLC early are handled.

void  
deferslc (void)  
{  
 if (def_slcbuf)  
   {  
     start_slc (1);  
     do_opt_slc (def_slcbuf, def_slclen);  
     end_slc (0);  
     free (def_slcbuf);  
     def_slcbuf = (unsigned char *) 0;  
     def_slclen = 0;  
   } 
    
}

The exploit uses this path intentionally: send the malicious SLC payload before TTYPE, let the server save it, then send TTYPE to trigger deferslc() and the overflow.

Data limitations

The payload can't be any bytes. Every triplet goes through process_slc and change_slc before add_slc writes it to the buffer. These functions apply rules:

  • If func > 18: the flag and val bytes are discarded, replaced with zeros.
  • If func == 0: the triplet is not written at all. Instead, it triggers send_slc(), which writes the server's own SLC table to the buffer. This is useful for amplification.
  • The flag byte is modified depending on the negotiation path - a bit may be set or cleared, or the lower bits may be overwritten with a server-defined level value.
  • Any byte equal to 0xFF is doubled in the output.

These limitations mean that writing specific byte values to specific positions in memory requires careful construction of the triplet sequence.

Memory layout

All the relevant variables are in .bss section (uninitialized global data). In the vulnerable build, they sit in this order:

0x0806a860  slcbuf      [108 bytes]  <- overflow starts here
0x0806a8cc  buf.0       [5 bytes]
0x0806a8e0  argp_program_version_hook  [4 bytes, function pointer]
...
0x0806a944  .got.plt    <- GOT starts here

The distance from the end of slcbuf to the start of the GOT is 120 bytes on the stock build. The maximum overflow reach is about 400 bytes, so the GOT is within range. However, reaching it is only half the problem.

The 32-bit RCE exploit uses a purpose-built environment: a custom linker script (got_after_bss.ld) places the GOT immediately after BSS with no gap, making the layout deterministic regardless of compiler decisions. A padding library loaded via LD_PRELOAD shifts the libc base address into a range where the target function's bytes pass through the triplet constraints.

Exploitation: 32-bit

The goal is to overwrite a GOT entry with the address of a libc function. After the overwrite, the next call to the original function jumps somewhere else instead - ideally somewhere that executes a command as root. Getting there requires solving three problems simultaneously: advancing slcptr precisely to the GOT entry, writing the right bytes despite change_slc mutating every triplet, and doing it all without triggering a crash before the overwrite lands.

Three techniques

  • Amplification. Normally each triplet produces 3 bytes of output in the buffer. But a triplet with func=0 and flag=SLC_VARIABLE is special - instead of writing itself, it triggers the server to dump its own SLC table into the buffer, around 15 bytes per trigger. The exploit uses several of these to advance slcptr efficiently across BSS without sending large amounts of data over the wire.

  • Alignment shift. The bytes of the target address need to land on specific positions within the GOT entry. A triplet with func=0xFF takes up 4 bytes in the buffer instead of 3, because 0xFF is doubled by protocol rules. Two of these triplets nudge the alignment by +2 bytes, putting the following triplets exactly where they need to be.

  • Byte control through change_slc. The bytes of the target address cannot be written directly - each one goes through change_slc which modifies it before it hits the buffer. The exploit reverses this: for each byte of the address, it works backwards from the desired output to figure out what input produces it. For example, change_slc always forces the high bit of the flag to 1 on the NOSUPPORT path - so to write a byte with that bit clear, you send it clear and let change_slc set it, then pick a path where the result matches your target byte.

The libc constraint

This is where the 32-bit path gets environment-specific. On i386, a GOT entry is 4 bytes. Each byte maps to either a func, flag, or val position in a triplet. The constraints per position are:

  • func: must be 1-18
  • flag: lower 2 bits cannot be 0x03, bit 7 is forced by most change_slc paths
  • val: almost anything except 0xFF

For a libc function address to be writable through these constraints, each of its 4 bytes must be achievable at the position it lands on. This depends entirely on the libc base address. The exploit was verified in a Docker i386 container running under QEMU on Apple Silicon, where libc loads around 0x40xxxxxx - producing byte values that fit. On native x86_64 Linux hosts running the same container, libc loads around 0xf7xxxxxx where byte[2] of most functions comes out above 18, blocking the func position entirely.

Payload verification

Before sending anything, the exploit simulates the entire add_slc output locally in Python and checks that the bytes at the GOT offset match the target address exactly. If they don't match, the exploit exits rather than sending a broken payload.

actual = output[got_off:got_off+4]
expected = list(struct.pack('<I', libc_base + execvp_offset))
if actual != expected:
    sys.exit(1)

This simulation is what makes the exploit reliable when the environment is right - and what makes it refuse to run when it isn't.

Execution flow

When the environment is correctly set up, the sequence is:

1. Connect TCP, send WILL LINEMODE before TTYPE
   Server defers SLC processing because terminit() is false
2. Send SLC payload: amplification -> padding -> alignment -> GOT write
   Payload sits in def_slcbuf on the heap
3. Send TTYPE
   terminit() becomes true, deferslc() fires
   add_slc() runs without bounds check -> overflow
   GOT[sprintf] = address of execvp
4. Send one more SLC suboption
   Server calls sprintf() as part of start_slc()
   sprintf() now points to execvp()
   execvp receives telnet protocol data as its argument, not a valid
   command path, so no shell spawns - but the process terminates,
   confirming the GOT overwrite landed and code flow was redirected

The exploit output reflects this:

[+] sprintf@GOT overwritten → execvp (0x410125a0)
[+] Server process terminated (GOT hijack confirmed)
[+] RCE DEMONSTRATED

The termination confirms the overwrite worked. Turning this into actual command execution would require either controlling what reaches the overwritten function as its first argument, or choosing a different GOT target whose call site passes attacker-controlled data.

Why 64 bit won't work this way

On a 64 bit system, pointers are 8 bytes wide. Any valid userspace address looks like this in memory (little-endian):

execvp = 0x00007f4a2b3c1234

Bytes in memory:
+0: 34
+1: 12
+2: 3c
+3: 2b
+4: 4a
+5: 7f
+6: 00   <- must be zero
+7: 00   <- must be zero

The upper two bytes of every valid 64 bit userspace address are always 0x00. To overwrite a GOT entry you need to write these zeros to the correct positions.

The triplet format makes this impossible. Each triplet has three byte slots with different rules, and 0x00 is only safely writable through the val slot. The func slot cannot be zero - a zero func triggers amplification instead of a write. The flag slot cannot be zero either - change_slc always modifies the lower bits of the flag before writing, so a zero input never gives back a zero output.

Since the two required zero bytes always end up landing on func or flag slots regardless of how you align the payload, there is no combination of triplets that writes a valid 64-bit pointer. This is not a matter of finding a cleverer payload - the constraint is mathematical and holds for every possible write target in the process.

64 bit lab exploit

Since the overflow alone cannot produce a valid 64 bit pointer, Striga's 64 bit exploit splits the work across 3 stages.

Stage 1 sends an amplified SLC payload that overflows slcbuf past BSS and into the heap. This demonstrates that the out-of-bounds write is real and controllable, but stops before RCE, because there is no way to produce a valid pointer through the triplet constraints.

Stage 2 handles the GOT overwrite through a different primitive entirely. Given root or same UID access to the target process, the exploit reads /proc/<pid>/maps to confirm runtime addresses, then writes popen() directly to sprintf@GOT via /proc/<pid>/mem. The next time the server calls sprintf(), which happens as part of normal SLC processing, it jumps to popen() instead and executes an arbitrary shell command as root.

Together the two stages prove: the vulnerability is exploitable, and the RCE primitive works. In a real attack scenario, Stage 2 would require proper foothold on the host. In the Docker lab setup, docker exec provides necessary access.

Stage 1: SLC Overflow

A calibrated amplified payload is sent through the normal Telnet connection. The overflow writes past slcbuf into adjacent BSS variables, demonstrating that the out-of-bounds write is real and controllable. The payload is sized to land in a predictable region without triggering an immediate crash.

Stage 2: GOT Overwrite via /proc/PID/mem

Linux exposes a file at /proc/<pid>/mem that maps to the virtual address space of the process. A process with sufficient permissions can read and write this file directly, bypassing all memory protection at the OS level.

The exploit locates the telnetd child process spawned by inetd, reads /proc/<pid>/maps to confirm the runtime addresses of libc and the GOT, then writes the address of popen() directly over sprintf@GOT:

with open(f"/proc/{pid}/mem", "rb+") as mem:
    mem.seek(got_address)
    original = struct.unpack("<Q", mem.read(8))[0]
    mem.seek(got_address)
    mem.write(struct.pack("<Q", popen_address))

With the GOT patched, sending any SLC suboption causes the server to call start_slc(), which internally calls sprintf(). That call now jumps to popen() instead. The first argument passed - the format string "%c%c%c%c" - becomes the command string for popen(). The exploit pre-stages a command at a known path, or the /proc/mem write can target a different function whose first argument is attacker-controlled.

[+] GOT overwrite successful:
    sprintf@GOT (0x4377b8): 0x0000fffff7dc6f20 -> 0x0000fffff7e2f680  (popen)
[+] Exploit chain complete - RCE demonstrated

Reproduction

Both exploits ship with Docker containers for a self-contained test environment.

32-bit:

docker build --no-cache --platform linux/i386 -f Dockerfile.x32 -t vuln-x32 .
docker run --platform linux/i386 --privileged -d -p 2325:23 vuln-x32
python3 exploit_i386_poc.py 127.0.0.1 -p 2325

64-bit:

docker build --no-cache --platform linux/amd64 -f Dockerfile.x64 -t vuln-x64 .
docker run --privileged -d --name vuln-x64 -p 2324:23 vuln-x64
python3 exploit_64bit_procmem_poc.py 127.0.0.1 -p 2324 --container vuln-x64

Both containers disable ASLR and compile without stack protectors, PIE, or RELRO to match the conditions under which the original vulnerability was analyzed.

Impact

The vulnerable code is not specific to GNU inetutils. The same slc.c with the same missing bounds check was copied into:

  • Ubuntu, Debian, most Linux distributions
  • FreeBSD 13 and 15, NetBSD 10.1, DragonFlyBSD
  • Citrix NetScaler
  • Apple macOS (remote_cmds)
  • Haiku, TrueNAS Core, uCLinux, libmtev

32-bit targets where GOT overwrite via the SLC overflow is achievable include MIPS32-based Juniper SRX branch devices, ARM32-based Synology and QNAP NAS devices, PowerPC32-based Brocade SAN switches, and legacy x86 servers running 32-bit Linux.

On all of these, exploitation requires that the memory layout places a useful write target within about 400 bytes after slcbuf. The exact layout differs by compiler version, build flags, and link order; meaning each target needs individual analysis.

Fix

The fix is a single bounds check added to add_slc. A patched version was committed to the inetutils repository, but no release has been cut at the time of writing. The most recent tarball (2.7) is still vulnerable. Distributors who ship from the tarball have not yet released updates.

The only reliable mitigation right now is to compile patched code yourself, disable Telnet where possible, or to block port 23 in firewall. For systems where Telnet cannot be removed, rebuilding from the patched commit is the only option.

Downloads