Nieuws

Devblog – Decompilers, een oogje onder de motorkap

Een decompiled method

Bij HUSS wordt er veelal gebruik gemaakt van C# .NET. Dit brengt veel voordelen met zich mee, meer dan veel developers zouden denken.

Voor de developer die niet bang is hun handen een beetje vuil te maken, brengt dit nog meer voordelen met zich mee.

Bytecode

In veel low-level talen zoals C/C++ wordt de code gecompileerd voor een specifiek platform, zoals x64. De code is zover geoptimaliseerd dat je de source code er nauwelijks in kan herkennen. Neem bijvoorbeeld dit C++ voorbeeld (code expres wat omslachtig voor demonstratie doeleinden):

#include <iostream>

int square(int num)
{
    int myResult = num * num;
    std::cout << myResult;
    return myResult;
}

Nadat dit is gecompileerd krijg je alleen een stel willekeurig-uitziende bytes. Als je dit door een decompiler heen haalt, dan krijg je dit:

square(int):
        push    rbx
        mov     ebx, edi
        imul    ebx, edi
        mov     edi, OFFSET FLAT:std::cout
        mov     esi, ebx
        call    std::ostream::operator<<(int)
        mov     eax, ebx
        pop     rbx
        ret
_GLOBAL__sub_I_square(int):
        sub     rsp, 8
        mov     edi, OFFSET FLAT:std::__ioinit
        call    std::ios_base::Init::Init() [complete object constructor]
        mov     edx, OFFSET FLAT:__dso_handle
        mov     esi, OFFSET FLAT:std::__ioinit
        mov     edi, OFFSET FLAT:std::ios_base::Init::~Init() [complete object destructor]
        add     rsp, 8
        jmp     __cxa_atexit

Met een beetje moeite kan je achterhalen wat dit doet, maar het valt niet mee. Variabele-namen kan je wel vergeten, die worden weg geoptimaliseerd.

Laten we nu hetzelfde proberen in C#:

class Program
{
    static int Square(int num)
    {
        int myResult = num * num;
        Console.WriteLine("[Debug] " + myResult);
        return myResult;
    }
}

En wat de compiler er van heeft gemaakt:

Nog steeds wat lastiger te lezen, maar we hebben nu al veel meer informatie. We hebben zelfs de variabele naam myResult nog, ook met code optimizations! Hier zien we: vermenigvuldig num met zichzelf, resultaat opslaan in myResult, het console aanroepen met het resultaat, en daarna het resultaat returnen.

Decompilatie

Laten we het programma van hierboven door een decompiler heen halen. In dit geval heb ik dnSpy gebruikt, maar er zijn veel andere programma’s die dit ook kunnen (waarvan minstens 1 in Visual Studio zitten ingebakken). Als we de .DLL openen in dnSpy, dan zien we dit:

using System;

// Token: 0x02000002 RID: 2
internal class Program
{
	// Token: 0x06000001 RID: 1 RVA: 0x00002050 File Offset: 0x00000250
	private static int Square(int num)
	{
		int myResult = num * num;
		Console.WriteLine("[Debug] " + myResult.ToString());
		return myResult;
	}
}

Wonderbaarlijk genoeg, bijna dezelfde code, letter voor letter. Dit komt omdat C# programma’s op vrijwel ieder platform moeten kunnen draaien (Windows, Windows ARM, Linux x64, etc). Daarom wordt C# code niet gecompileerd voor een specifiek platform, maar gecompileerd naar een soort-van “tussenvorm”. Deze tussenvorm behoudt genoeg van de originele code dat we hier veel interessante dingen mee kunnen doen.

Dit gebruikt Microsoft zelf bijvoorbeeld, om meer inzicht te geven in de performance van code die in Azure draait. Hiermee geeft Azure bijvoorbeeld inzicht in welke methodes of delen van het programma het langst duren, of de meeste CPU-tijd in beslag nemen.

Live-code inspecties, debugging, en aanpassingen

Ik wil graag benadrukken dat dit soort praktijken alleen “in geval van nood” op productie moeten worden toegepast. Als je op deze manier code aanpast, dan is stap 2 altijd “de aanpassing doorvoeren in source control, en een release inplannen met de bestaande pipelines”.

Met deze disclaimer terzijde, laten we kijken naar een praktisch voorbeeld van wanneer dit soort tools erg handig zijn. Dit praktisch voorbeeld is slechts een reconstructie van echte scenarios die ik heb meegemaakt, maar dit brengt het punt voldoende over.

Wel verbinding met de server maar geen code, wat nu?

Vaak als je toegang hebt tot een IIS webserver waar de applicatie in draait, dan heb je toegang tot de logs, de bestanden, en de machine. Maar toch geen source code? Geen probleem.

Laten we een variatie nemen van het standaard C# ASP.NET Core template project: een “weerbericht” API. Je zit met een legacy applicatie van 15 jaar geleden die eerst altijd in Los Angeles, Californië draaide, maar ineens de verkeerde resultaten terug geeft in Nederland.

Vanaf dit punt laat ik de originele code niet meer zien naast de gedecompileerde code, ze komen namelijk ~99% overeen (ja, serieus):

[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
	[NullableContext(1)]
	[HttpGet(Name = "Today")]
	public WeatherForecast Today()
	{
		int celsius = -10;
		return new WeatherForecast
		{
			Date = DateOnly.FromDateTime(DateTime.Now),
			TemperatureC = celsius,
			TemperatureF = WeatherForecastController.ToFahrenheit(celsius),
			Summary = "Mild"
		};
	}

	private static int ToFahrenheit(int celsius)
	{
		int fahrenheit = Math.Abs(celsius) * 9 / 5 + 32;
		return (celsius < 0) ? (-fahrenheit) : fahrenheit;
	}
}

Er klopt helemaal niets van deze fahrenheit conversie code voor temperaturen onder de 0. Dit viel eerst nooit op, omdat de applicatie altijd in LA draaide.

Live code aanpassingen

Laten we een kleine variatie nemen op de bovenstaande code. Deze keer zullen we er vanuit gaan dat we wel source code hebben, maar geen flauw idee waarom er iets misgaat:

using Microsoft.AspNetCore.Mvc;

namespace WebApplication2.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class WeatherForecastController : ControllerBase
    {
        [HttpGet(Name = "Today")]
        public WeatherForecast Today()
        {
            try
            {
                int celsius = -10;
                return new WeatherForecast()
                {
                    Date = DateOnly.FromDateTime(DateTime.Now),
                    TemperatureC = celsius,
                    TemperatureF = ToFahrenheit(celsius),
                    Summary = "Mild"
                };
            }
            catch (Exception e)
            {
                return null;
            }
        }

        private static int ToFahrenheit(int celsius)
        {
            if (celsius < 0)
            {
                throw new ArgumentException("Temperature cannot be below 0!");
            }
            int fahrenheit = Math.Abs(celsius) * 9 / 5 + 32;
            return celsius < 0 ? -fahrenheit : fahrenheit;
        }
    }
}

Dit soort “wegstoppen” van errors word je natuurlijk erg blij van, zo blij dat je gelijk zin krijgt om meer koffie te gaan halen. Van de ene op de andere dag geeft de API ineens niets meer terug, en het is niet duidelijk waarom, en lokaal krijg je het ook niet gereproduceerd. Natuurlijk in dit voorbeeld is het vrij simpel te zien, maar in echte code komt het probleem vaak van dieper.

Ook dit kan je duct-tapen met een decompiler zoals dnSpy. Als je niet weet waar de error vandaan komt, dan is het wellicht al voldoende om error-logging toe te passen. Even de reference assemblies toevoegen (in dit geval vanuit de C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\10.0.8\ref\net10.0 map), en dan de code een beetje wijzigen:

Even saven, en hup, je bent al klaar. Het .DLL bestand van je applicatie is omgewisseld met je wijzigingen, en nu zie je duidelijk in de logs waar het vandaan komt.

Temperature cannot be below 0!
   at WebApplication2.Controllers.WeatherForecastController.ToFahrenheit(Int32 celsius) in C:\Repos\WebApplication2\WebApplication2\Controllers\WeatherForecastController.cs:line 35
   at WebApplication2.Controllers.WeatherForecastController.Today() in C:\Repos\WebApplication2\WebApplication2\Controllers\WeatherForecastController.cs:line 19

Debugging

Aangezien dit soort programma’s de code kunnen decompilen, en er een code editor ingebakken zit, kan je natuurlijk ook een applicatie live debuggen!

Als je dit wil toepassen op IIS, dan moet je wel “shadow copying” van de DLL’s uitzetten, anders als je de DLL selecteert om in te debuggen dan selecteer je de verkeerde. Dit komt omdat IIS bij het opstarten van een applicatie de DLL’s naar een interne map kopiëert voordat deze worden gerund. Dit kan je bijvoorbeeld zo instellen in je web.config:

<?xml version="1.0"?>
<configuration>
    <system.web>
      <compilation debug="true" targetFramework="4.5" />
      <httpRuntime targetFramework="4.5" />
      <hostingEnvironment shadowCopyBinAssemblies="false" />
    </system.web>
</configuration>

En dan simpelweg attachen zoals je dat ook zou doen in Visual Studio:

Logistieke problemen

Dit voorbeeld gaat ervan uit dat je toegang hebt tot de server. dnSpy is licht en compact genoeg dat je het vrijwel overal kwijt kan. Ik heb het zelf in het verleden gebruikt om soortgelijke issues te debuggen op een oudere Windows Server, waar Remote Destop beschikbaar was. Helaas heb je die luxe niet altijd. In dat geval zal je het toch van andere tooling moeten hebben.

dnSpyEx (de meest recente fork van dnSpy) kan je hier vinden: https://github.com/dnSpyEx/dnSpy