Brigitte Friang CTF 2020 - Steganausorus

I solved this reverse/stegano challenge during the Brigitte Friang CTF organized by ESIEE and DGSE (during 2 weeks). We managed to finish 7th out of 779 teams with our team InjeXtion, finishing all 14 challenges after a week of intense work.

Steganausorus (400pts)

The resource provided for this challenge is a USB filesystem that an agent has managed to find.

First thing to do, we check the filetype:

kali:steganosaurus$ file message
message: DOS/MBR boot sector, code offset 0x58+2, OEM-ID "mkfs.fat", Media descriptor 0xf8, sectors/track 32, heads 64, hidden sectors 7256064, sectors 266240 (volumes > 32 MB), FAT (32 bit), sectors/FAT 2048, reserved 0x1, serial number 0xccd8d7cd, unlabeled

As it is a filesystem, we are going to mount it on our system as follows:

kali:steganosaurus$ sudo mount message /media/luquf

We then move to the directory where we mounted the filesystem, and list all the files available in it:

kali:luquf$ ls -la
rwxr-xr-x 1 root root      532 oct.  15 11:48 readme
-rwxr-xr-x 1 root root 38221331 juil.  8 10:02 steganausorus.apk
drwxr-xr-x 4 root root      512 oct.  25 12:53 .Trash-1000

Cool, there is a readme file, an apk (android application format), and the trash hidden directory. Let's first check the readme:

kali:luquf$ cat readme 
Bonjour evilcollegue !
Je te laisse ici une note d'avancement sur mes travaux !
J'ai réussi à implémenter complétement l'algorithme que j'avais présenté au QG au sein d'une application.
Je te joins également discrétement mes premiers résultats avec de vraies données sensibles ! Ils sont bons pour la corbeille mais ça n'est que le début !
Je t'avertis, l'application souffre d'un serieux defaut de performance ! je m'en occuperai plus tard.
contente-toi de valider les résultats.
Merci d'avance

For the worst,

QASKAB

So the guy who wrote it claims that he implemented an algorithm inside an aplication (the apk file), and that he joined some of the result produced by the application, even though there are good for the trash. That's a hint, let's check the trash:

kali:luquf$ ls .Trash-1000/files/
flag.png

flag.png

The image seems legit, but we can see some weird colored pixels on the top left corner. We can fairly deduce from this image that the android application is implementing a steganography algorithm. Next step: reverse the android application.

As I don't have any android emulator/tools installed on my kali VM, and I don't want to loose time, I am going to reverse the application statically.

The first step is to open the apk file with jadx, an android decompiler to recover the files inside the apk (the apk is actually a zip file). It can also be done with apktools.

Once the apk decompiled, we can explore the main package com.example.stegapp. The name confirms that it is a steganography application.

We can see two important things, the MainActivity code:

MainActivity

And the BuildConfig code:

BuildConfig

The MainActivity is empty and inheritates from the class FlutterActivity, so the app has been built with flutter, a mobile framework.

In the second image, we can see that the app has been built in debug mode. One particular thing with debug mode in flutter is that the code and the comments are embedded in a file named kernel_blob.bin. This file is located at assets/flutter_assets/kernel_blob.bin.

kali:steg$ cat assets/flutter_assets/kernel_blob.bin | wc -l
595236

If we recover the strings from the file, we can see a lot of code. This is some dart code, a programming language created by google.

Another particular thing is that in this massive chunk of code, the entry point is usually the class MyApp. With a little research on the file, there are 22 occurences of MyApp in kernel_blob.bin. 21 of them are commented, and the 22nd is the good one. We have the main code of the application.

import 'dart:convert';
import 'dart:math';
import 'dart:ui';
import 'package:flutter/services.dart';
import 'package:image/image.dart' as A;
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:color/color.dart';
void main() => runApp(MyApp());

// #docregion MyApp
class MyApp extends StatelessWidget {
  // #docregion build
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Startup Name Generator',
      home: MyHomePage(),
    );
  }
}
  class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
  }

  class _MyHomePageState extends State<MyHomePage> {
  File _image;
  final picker = ImagePicker();
  bool _btnEnabled = false;
  final myController = TextEditingController();
  String tempPath;

  Future getImage() async {
  final pickedFile = await picker.getImage(source: ImageSource.gallery);

  setState(() {
  _image = File(pickedFile.path);
  });
  }

  String MessageToBinaryString(String pMessage){
    String Result;
    Result="";
    List<int> bytes = utf8.encode(pMessage);
    bytes.forEach((item) { Result+=item.toRadixString(2).padLeft(8,'0');});
    return Result;
  }
  Future<void> steggapp(File pImage, String pMessage) async {
    //Declaration
    String ImagePath;
    String binaryStringmessage;
    String binaryStringImage;
    String binaryStringData;
    Directory tempDir = await getTemporaryDirectory();
    tempPath  = tempDir.path;
    print(tempPath);
    List<List<int>> DataList =  List<List<int> >();

    //Initialisation




    //get the two binary string from parameters
    binaryStringmessage= MessageToBinaryString(pMessage);
    ImagePath = pImage.path;

    File image =new File(ImagePath);

    var decodedImage =await  decodeImageFromList(image.readAsBytesSync());
    ByteData imgbyte=await decodedImage.toByteData();
    var imgintlist = imgbyte.buffer.asUint8List();


    A.Image aimage =A.Image.fromBytes(decodedImage.width,decodedImage.height, imgintlist, format: A.Format.rgba);
    A.Image resisedimage=A.copyResize(aimage,width:1000);

    String RRGGBBString;
    String RedBinString;
    String BlueBinString;
    String GreenBinString;
    String PixelString;
    String MegaString;

    MegaString="";
    for (int i = 0;i < resisedimage.length;i++){
      RRGGBBString=resisedimage[i].toRadixString(2).padLeft(32, '0').substring(8);
      PixelString=RRGGBBString.substring(16,24)+RRGGBBString.substring(8,16)+RRGGBBString.substring(0,8);
      MegaString+=PixelString;

    }
    int messaggelength=0;
    String messagetohide=binaryStringmessage;
    String substringtoFind;
    substringtoFind=messagetohide.substring(0,1);

    String Stringbuilttest="";
    var offsetarray = new List();
    int offsettostore;
    int lengthtostore;
    int offset;
    String Megastringtosearch= MegaString.substring((MegaString.length/4).round());
    print("performing data calculation");
    while(messaggelength < binaryStringmessage.length ) {
      offsettostore=Megastringtosearch.indexOf(substringtoFind);

      print(Megastringtosearch.substring(offsettostore,offsettostore+substringtoFind.length));
      while(offsettostore !=-1 && substringtoFind.length<=messagetohide.length-1){
          lengthtostore = substringtoFind.length;
          offset = offsettostore;
          substringtoFind = messagetohide.substring(0, substringtoFind.length + 1);
          offsettostore = Megastringtosearch.indexOf(substringtoFind);
        }


    if(substringtoFind.length == messagetohide.length  ){
      int lastoffsettostore=Megastringtosearch.indexOf(substringtoFind);
      if(lastoffsettostore==-1){
        offsetarray.add([offset, lengthtostore]);
        offsetarray.add([Megastringtosearch.indexOf(substringtoFind[-1]),1]);

        Stringbuilttest+=Megastringtosearch.substring(Megastringtosearch.indexOf(substringtoFind[-1]),(Megastringtosearch.indexOf(substringtoFind[-1])+lengthtostore));
      }
      else{
        offsetarray.add([Megastringtosearch.indexOf(substringtoFind),substringtoFind.length]);
        var lastitem=offsetarray.last;
        Stringbuilttest+=Megastringtosearch.substring(Megastringtosearch.indexOf(substringtoFind),(offsettostore+substringtoFind.length));
      }
      messaggelength+=substringtoFind.length;
    }
    else {
      messagetohide = messagetohide.substring(substringtoFind.length - 1);
      messaggelength += substringtoFind.length;
      Stringbuilttest +=
          Megastringtosearch.substring(offset, (offset + lengthtostore));
      offsetarray.add([offset, lengthtostore]);

      offsettostore = 0;
      lengthtostore = 1;
      offset = 0;

      substringtoFind = messagetohide.substring(0, 1);
    }


  }


    int offsetdatasize=resisedimage.length*8*3;

    int lenghtdatasize=binaryStringmessage.length;
    int lenghtsizebit=lenghtdatasize.toRadixString(2).length;;
    int datasizebit= offsetdatasize.toRadixString(2).length;

    String stringtowrite="";

    stringtowrite+=offsetarray.length.toRadixString(2).padLeft(datasizebit,'0')+lenghtsizebit.toRadixString(2).padLeft(datasizebit,'0');

    offsetarray.forEach((listofdata){
      listofdata.forEach((data){
        //print(data.toRadixString(2).padLeft(datasizebit,'0'));
        stringtowrite+=listofdata[0].toRadixString(2).padLeft(datasizebit,'0')+listofdata[1].toRadixString(2).padLeft(lenghtsizebit,'0');
      });
    int lengthofmodifiedstring=stringtowrite.length;
    List<int> pixelvalue= new List();
    int compteur = 0;
    int missingsize;

    String finaleImageString;
    finaleImageString=stringtowrite+MegaString.substring(stringtowrite.length);
    int limit;
    limit=stringtowrite.length;
    while(compteur <limit){
      try {

        pixelvalue.add(int.parse(stringtowrite.substring(0, 8),radix: 2));
        stringtowrite=stringtowrite.substring(8);
        compteur+=8;

      }
      on RangeError {
        missingsize=8-stringtowrite.length;
        pixelvalue.add(int.parse(stringtowrite+finaleImageString.substring(compteur+stringtowrite.length,compteur+stringtowrite.length+missingsize),radix: 2));
       compteur+=8;
      }
    }

    A.Image imagetosave;
    int compteurpixel;
    imagetosave= resisedimage.clone();
    compteurpixel =0;
    List<int> lastpixellist = new List();
    for(int iz=0;iz<pixelvalue.length;iz+=3){
      try{
        var testpixel=pixelvalue[iz+2];
        imagetosave.data[compteurpixel]=A.getColor(pixelvalue[iz],pixelvalue[iz+1], pixelvalue[iz+2]);
        compteurpixel+=1;
      }
      on RangeError{
        pixelvalue=pixelvalue.sublist(iz);
        var basixpixellist=imagetosave.data[compteurpixel].toRadixString(2).padLeft(32, '0').substring(8);
        int RedChannelint=int.parse(basixpixellist.substring(16,24),radix: 2);
        int GreenChannelint=int.parse(basixpixellist.substring(16,24),radix: 2);
        int BlueChannelint=int.parse(basixpixellist.substring(16,24),radix: 2);
        List<int> originalpixelvalue = [RedChannelint,GreenChannelint,BlueChannelint];
        for(int ze=0;ze<=2;ze++){
          if (ze > pixelvalue.length-1){
          lastpixellist.add(originalpixelvalue[ze]);
          }  else {
            lastpixellist.add(pixelvalue[ze]);
          }
        }
        imagetosave.data[compteurpixel]=A.getColor(lastpixellist[0],lastpixellist[1], lastpixellist[2]);
      }
    }

    Directory documentD= await getExternalStorageDirectory();
    new File(documentD.path+'/thumbnail-test.png')..writeAsBytesSync(A.encodePng(imagetosave));

    //binaryStringImage =ImageToBinary(pImage);
//
//    //calculate the list [numberofbytestoread,[offset,len],[offset,len],...]
//
//    DataList=stegalg(binaryStringmessage,binaryStringImage);
//
//    //convert DataList to binary Stream
//
//    binaryStringData=ListToBin(DataList);
//
//    //Apply this binary List to pixel Array
//    pixelArray=incorporateData(binaryStringData,binaryStringImage);
//
//    //Generate and save the image
//    Targetimage= GenerateImage(pixelArray);

  }

  @override
  Widget build(BuildContext context) {
  return Scaffold(
  appBar: AppBar(
  title: Text('Steganausorus App'),
  ),
  body: Column(
    crossAxisAlignment: CrossAxisAlignment.center,
    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
    children: <Widget>[
      Text('Select the message you want to hide'),
      TextFormField(
        controller: myController,
        autovalidate: true,
        validator:(String txt){
           bool isValid = txt.length >= 1;
           if(isValid != _btnEnabled){
            WidgetsBinding.instance.addPostFrameCallback((_) {setState(() {
              _btnEnabled =txt.length >= 1;
            });});
        }
    },

        textAlign: TextAlign.center,
        decoration: InputDecoration(

            border: UnderlineInputBorder(),
            hintText: 'I am a very well hidden message',
        ),
      ),
      Text('Select the target Image :'),
     _image == null
         ? Text('No image selected.')
          : Image.file(_image),

      RaisedButton(onPressed: _btnEnabled == true && _image != null ?  () {steggapp(_image, myController.text);} : null ,
        child: const Text('Start Hide & seek game', style: TextStyle(fontSize: 20)),),
    ],

  ),
  floatingActionButton: FloatingActionButton(
  onPressed: getImage,
  tooltip: 'Pick Image',
  child: Icon(Icons.add_a_photo),
  ),
  );
  }
  }

Damn daniel, this code looks awful. There are comments left to help us:

//    binaryStringImage =ImageToBinary(pImage);
//
//    //calculate the list [numberofbytestoread,[offset,len],[offset,len],...]
//
//    DataList=stegalg(binaryStringmessage,binaryStringImage);
//
//    //convert DataList to binary Stream
//
//    binaryStringData=ListToBin(DataList);
//
//    //Apply this binary List to pixel Array
//    pixelArray=incorporateData(binaryStringData,binaryStringImage);
//
//    //Generate and save the image
//    Targetimage= GenerateImage(pixelArray);

These are the differents steps of the algorithm. Let's identify these in the code and analyze each one.

First, binaryStringImage=ImageToBinary(pImage);:

The code corresponding to it is the following:

//get the two binary string from parameters
binaryStringmessage= MessageToBinaryString(pMessage);
ImagePath = pImage.path;

File image =new File(ImagePath);

var decodedImage =await  decodeImageFromList(image.readAsBytesSync());
ByteData imgbyte=await decodedImage.toByteData();
var imgintlist = imgbyte.buffer.asUint8List();


A.Image aimage =A.Image.fromBytes(decodedImage.width,decodedImage.height, imgintlist, format: A.Format.rgba);
A.Image resisedimage=A.copyResize(aimage,width:1000);

String RRGGBBString;
String RedBinString;
String BlueBinString;
String GreenBinString;
String PixelString;
String MegaString;

MegaString="";
for (int i = 0;i < resisedimage.length;i++){
  RRGGBBString=resisedimage[i].toRadixString(2).padLeft(32, '0').substring(8);
  PixelString=RRGGBBString.substring(16,24)+RRGGBBString.substring(8,16)+RRGGBBString.substring(0,8);
  MegaString+=PixelString;

}

It first converts the string message to hide to a binary string (e.g. 0100101...), and then converts the image pixels as a binary string also. For this, it takes all the pixels line by line and concatenate all the bits from the red, green, and blue components. It does not use the fourth pixel component. Here is what MegaString looks like:

| bin(pixel[0][0][0]) (red) | bin(pixel[0][0][1]) (green) | bin(pixel[0][0][2]) (blue) |...

Then, DataList=stegalg(binaryStringmessage,binaryStringImage);

The code corresponding to it is the following:

int messaggelength=0;
String messagetohide=binaryStringmessage;
String substringtoFind = messagetohide.substring(0,1);
String Stringbuilttest="";
var offsetarray = new List();
int offsettostore;
int lengthtostore;
int offset;
String Megastringtosearch= MegaString.substring((MegaString.length/4).round());

while(messaggelength < binaryStringmessage.length ) {
  offsettostore=Megastringtosearch.indexOf(substringtoFind);
  while(offsettostore !=-1 && substringtoFind.length<=messagetohide.length-1){
      lengthtostore = substringtoFind.length; 
      offset = offsettostore; 
      substringtoFind = messagetohide.substring(0, substringtoFind.length + 1);
      offsettostore = Megastringtosearch.indexOf(substringtoFind);
  }
  if(substringtoFind.length == messagetohide.length  ){
    int lastoffsettostore=Megastringtosearch.indexOf(substringtoFind);
    if(lastoffsettostore==-1){
      offsetarray.add([offset, lengthtostore]);
      offsetarray.add([Megastringtosearch.indexOf(substringtoFind[-1]),1]);
      Stringbuilttest+=Megastringtosearch.substring(Megastringtosearch.indexOf(substringtoFind[-1]),(Megastringtosearch.indexOf(substringtoFind[-1])+lengthtostore));
    }
    else{
      offsetarray.add([Megastringtosearch.indexOf(substringtoFind),substringtoFind.length]);
      var lastitem=offsetarray.last;
      Stringbuilttest+=Megastringtosearch.substring(Megastringtosearch.indexOf(substringtoFind),(offsettostore+substringtoFind.length));
    }
    messaggelength+=substringtoFind.length;
  }
  else {
    messagetohide = messagetohide.substring(substringtoFind.length - 1);
    messaggelength += substringtoFind.length;
    Stringbuilttest +=
        Megastringtosearch.substring(offset, (offset + lengthtostore));
    offsetarray.add([offset, lengthtostore]);
    offsettostore = 0;
    lengthtostore = 1;
    offset = 0;
    substringtoFind = messagetohide.substring(0, 1);
  }
  }

This part was the most complicated one to understand. First it creates a variable Megastringtosearch which is the three last quarters of the image Megastring. The reason it only searches in the last three quarters is that the data which will be embedded in the image will be located in the first pixels, so to avoid using pixels which will be modified after, it starts after the first quarter of the pixels. Then the algorithm iterates over the Megastringtosearch to find slices of the message to hide (binary format), and calculates the offset to reach it, and its length. It then adds both of these values in the offsetarray. The offsetarray is of the form [[offset, length], [offset, length]...]. To reconstruct the hidden message, we just have to get the length of bits of every item in offsetarray, at its offset, concatenate it and we will have the binary hidden message. Nice.

Then, binaryStringData=ListToBin(DataList);

The code corresponding to it is the following:

int offsetdatasize=resisedimage.length*8*3;

int lenghtdatasize=binaryStringmessage.length;
int lenghtsizebit=lenghtdatasize.toRadixString(2).length;
int datasizebit= offsetdatasize.toRadixString(2).length;

String stringtowrite="";

stringtowrite+=offsetarray.length.toRadixString(2).padLeft(datasizebit,'0')+lenghtsizebit.toRadixString(2).padLeft(datasizebit,'0');

offsetarray.forEach((listofdata){
  listofdata.forEach((data){
    //print(data.toRadixString(2).padLeft(datasizebit,'0'));
    stringtowrite+=listofdata[0].toRadixString(2).padLeft(datasizebit,'0')+listofdata[1].toRadixString(2).padLeft(lenghtsizebit,'0');
  });
int lengthofmodifiedstring=stringtowrite.length;
List<int> pixelvalue= new List();
int compteur = 0;
int missingsize;

String finaleImageString;
finaleImageString=stringtowrite+MegaString.substring(stringtowrite.length);
int limit;
limit=stringtowrite.length;
while(compteur <limit){
  try {

    pixelvalue.add(int.parse(stringtowrite.substring(0, 8),radix: 2));
    stringtowrite=stringtowrite.substring(8);
    compteur+=8;

  }
  on RangeError {
    missingsize=8-stringtowrite.length;
    pixelvalue.add(int.parse(stringtowrite+finaleImageString.substring(compteur+stringtowrite.length,compteur+stringtowrite.length+missingsize),radix: 2));
   compteur+=8;
  }
}

What this code does is the following. First, it creates an empty string which will contain all the binary data to hide called stringtowrite. The first two binary values inserted in this string are the length of the offsetarray and the the number of bits necessary to store the length of the binary message to hide. Both values are padded left of datasizebit. datasizebit is just the number of bits necessary to store the bit length of the image in RGB. It is 24. So we can ignore the first 48 bits of the image. Then the offset array is stored in the string in the form bin(offset)+bin(length). Both values are padded left of respectively datasizebit and lengthsizebit. We have to find the value of lengthsizebit. lengthsizebit is just the number of bits necessary to store the bit length of the binary message to hide. Let's go with 8. Here is what stringtowrite looks like:

| 24 bits | 24 bits | 24 bits | 8 bits | 24 bits | 8 bits |...

Once the binary string stringtowrite has been constructed, it is separated in 8 bit chunks which are inserted in an array pixelvalue. Notice that the code handles range errors.

Then, pixelArray=incorporateData(binaryStringData,binaryStringImage);

The code corresponding to it is the following:

A.Image imagetosave;
int compteurpixel;
imagetosave= resisedimage.clone();
compteurpixel =0;
List<int> lastpixellist = new List();
for(int iz=0;iz<pixelvalue.length;iz+=3){
  try{
    var testpixel=pixelvalue[iz+2];
    imagetosave.data[compteurpixel]=A.getColor(pixelvalue[iz],pixelvalue[iz+1], pixelvalue[iz+2]);
    compteurpixel+=1;
  }
  on RangeError{
    pixelvalue=pixelvalue.sublist(iz);
    var basixpixellist=imagetosave.data[compteurpixel].toRadixString(2).padLeft(32, '0').substring(8);
    int RedChannelint=int.parse(basixpixellist.substring(16,24),radix: 2);
    int GreenChannelint=int.parse(basixpixellist.substring(16,24),radix: 2);
    int BlueChannelint=int.parse(basixpixellist.substring(16,24),radix: 2);
    List<int> originalpixelvalue = [RedChannelint,GreenChannelint,BlueChannelint];
    for(int ze=0;ze<=2;ze++){
      if (ze > pixelvalue.length-1){
      lastpixellist.add(originalpixelvalue[ze]);
      }  else {
        lastpixellist.add(pixelvalue[ze]);
      }
    }
    imagetosave.data[compteurpixel]=A.getColor(lastpixellist[0],lastpixellist[1], lastpixellist[2]);
  }
}

What it does is simply iterate over the pixelvalue array and replace the original image pixels (in imagetosave) with a color value composed of 3 8-bit values from pixelvalue array (red, green, blue).

Finally, Targetimage= GenerateImage(pixelArray);. The image is generated (as a proper png file) with the hidden message in it.

Here is a script I have written in python to decode the hidden message in the image:

from PIL import Image
from math import ceil
import numpy

image1 = Image.open("flag.png", 'r')
image = image1.convert('RGB')
width, height = image.size
p = numpy.asarray(image)
offset_arr_len = 12 # detected by hand
mega_string = ""
flag_bin = ""
flag = ""

# building the "MegaString" variable
for i in range(len(p)):
        for j in range(len(p[i])):
                r, g, b = p[i][j]
                mega_string += '{0:08b}'.format(r)
                mega_string += '{0:08b}'.format(g)
                mega_string += '{0:08b}'.format(b)

data_size_bit = len(bin(width*height*8*3)[2:]) # 24
length_size_bit = 8

for i in range(2*data_size_bit, offset_arr_len*8*3, 32): # 12 is the number of values in the offset array (number of binary flag chunks)
        ms_offset = ceil(width*height*8*3/4) # rounded(1/4 of the image length)
        offset = int(mega_string[i:i+24], 2) + ms_offset # offset encoded on 24 bits + rounded(1/4 of the image length)
        length = int(mega_string[i+24:i+24+length_size_bit], 2) # length encoded on 8 bits
        flag_bin += mega_string[offset:offset+length] # get length of binary chunk at offset

for i in range(0, len(flag_bin), 8):
        flag += chr(int(flag_bin[i:i+8], 2))

print(f"Flag: {flag}")

And once executed, we get the flag, youhou !!

kali:steganosaurus$ python decoder.py 
Flag: DGSEESIEE{FL4GISH3R3}