2025 summer challenge writeup
Le mois dernier a eu lieu le Synacktiv Summer Challenge de 2025, un événement qui proposait une épreuve originale sur le thème des formats d'archives Podman. Vous avez été nombreux à y consacrer quelques heures : nous avons reçu plus d'une trentaine de tentatives ! Cet article a pour but de présenter et d'expliquer en détail les différentes étapes pour concevoir une solution optimale.
Vous souhaitez améliorer vos compétences ? Découvrez nos sessions de formation ! En savoir plus
Classement final
Félicitations aux 9 participants qui ont envoyé une solution valide. Vous trouverez ci-dessous le classement complet, incluant le score de chaque participant.
- #1 johndoe - 229.00
- #2 XeR - 233.99
- #3 ioonag - 395.18
- #4 taylorDeDordogne - 1661
- #5 a00n - 1712
- #6 xarkes - 4738
- #7 k8pl3r - 18432
- #8 julesdecube - 42099.68
- #9 quent - 555520
Nous saluons particulièrement la générosité du gagnant. Il a souhaité rester anonyme et a choisi de verser l'équivalent du premier prix, soit 200€, à l'association Médecins Sans Frontières ! Il est également le seul à avoir réussi le bonus de ce challenge, surpassant notre solution développée en interne qui obtenait un score de 229.97 :
$ echo -n "[>] average score over 500 tests -> 229.97" | sha256sum
c795ecf7692319832a62567ebdca26f4a7128197185bb019a1a139ad3b37ca58 -
OCI ou Docker archive ?
Selon le manuel de la commande podman load :
podman load loads an image from either an oci-archive or a docker-archive [...] podman load is used for loading from the archive generated by podman save
Le moyen le plus direct pour commencer nos expérimentations consiste donc à utiliser la commande podman save, sur l'image hello-world:latest par exemple, pour générer une archive au format oci-archive et une autre au format docker-archive.
Nous observons que dans les deux cas, il s'agit de simples archives tar contenant :
- Une sous-archive pour chaque layer qui compose le système de fichiers de l'image. Dans le cas du hello-world il n'y en a qu'une seule, et elle contient uniquement le petit exécutable hello.
- Des fichiers JSON qui décrivent toutes les métadatas associées à l'image, notamment la liste des layers, les tags et l'entrypoint de l'image.
Les spécifications du format Docker Image v1.x sont définies dans ce dépôt git, et celles du format Open Container Initiative (OCI) se trouvent dans celui-ci. A priori, il est difficile de savoir lequel de ces formats s'optimise le mieux pour obtenir le score le plus bas, car les deux permettent de produire des archives finales très petites. Néanmoins, le format oci-archive a l'inconvénient d'imposer la structure de Content Addressed Storage dans le répertoire blobs, ce qui ajoute un overhead important.
Par conséquent, nous allons nous concentrer sur le format historique de Docker Image dans sa version V1.3 :
$ podman save --format docker-archive -o test.tar hello-world:latest
Copying blob 53d204b3dc5d done
Copying config 1b44b5a3e0 done
Writing manifest to image destination
Storing signatures
$ tar xvf test.tar
53d204b3dc5ddbc129df4ce71996b8168711e211274c785de5e0d4eb68ec3851.tar
1b44b5a3e06a9aae883e7bf25e45c100be0bb81a0e01b32de604f3ac44711634.json
ccbb50ff49d360a84143aae385758520507df1c64e403698b61b91aa9d5d3f41/layer.tar
ccbb50ff49d360a84143aae385758520507df1c64e403698b61b91aa9d5d3f41/VERSION
ccbb50ff49d360a84143aae385758520507df1c64e403698b61b91aa9d5d3f41/json
manifest.json
repositories
$ tar xvf 53d204b3dc5ddbc129df4ce71996b8168711e211274c785de5e0d4eb68ec3851.tar
hello
Le blob 53d204b3dc5d, correspondant à l'unique layer de l'image, et la configuration 1b44b5a3e0 se retrouvent bien dans l'archive, ainsi que le manifest.json. La spécification précise que le fichier repositories et le répertoire ccbb50[...]3f41/ ne sont présents que pour des raisons de rétrocompatibilité, nous pouvons donc les ignorer.
Le format en détail
Le principal fichier JSON est le manifest.json. Il se compose d'une liste de dictionnaires, chacun associant un fichier de configuration à une liste de tags et une liste de layers. Dans le cas de l'image hello-world, il n'y a qu'un seul élément qui définit un tag et un layer.
[
{
"Config": "1b44b5a3e06a9aae883e7bf25e45c100be0bb81a0e01b32de604f3ac44711634.json",
"RepoTags": [
"docker.io/library/hello-world:latest"
],
"Layers": [
"53d204b3dc5ddbc129df4ce71996b8168711e211274c785de5e0d4eb68ec3851.tar"
]
}
]
Le fichier de configuration appelé Image JSON Description apporte quand à lui beaucoup plus d'informations. Cependant, en manipulant ce fichier et en supprimant progressivement des données, nous réalisons que les seuls champs réellement nécessaires sont :
- La valeur "entrypoint" ou "cmd" dans le dictionnaire "config" : cette valeur définit l'exécutable qui sera lancé lors de l'exécution d'un podman run sur cette image.
- Le champ "diff_ids" dans le dictionnaire "rootfs" : cette liste doit contenir les hash de chaque layer qui compose l'image. Podman va bien vérifier que les hash correspondent et si ce n'est pas le cas, refuser de charger l'image avec l'erreur "Digest did not match".
{
"architecture": "amd64",
"config": {
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
],
"Cmd": [
"/hello"
],
"WorkingDir": "/"
},
"created": "2025-08-08T19:05:17Z",
"history": [
{
"created": "2025-08-08T19:05:17Z",
"created_by": "COPY hello / # buildkit",
"comment": "buildkit.dockerfile.v0"
},
{
"created": "2025-08-08T19:05:17Z",
"created_by": "CMD [\"/hello\"]",
"comment": "buildkit.dockerfile.v0",
"empty_layer": true
}
],
"os": "linux",
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:53d204b3dc5ddbc129df4ce71996b8168711e211274c785de5e0d4eb68ec3851"
]
}
}
Une première solution
À ce stade nous avons toutes les informations nécessaires pour concevoir une solution "naïve". Le plus simple est de choisir un langage de programmation permettant de compiler un binaire statique afin que le layer de notre image ne contienne qu'une seule entrée. Le code complet en C++ est présenté à la fin de cet article.
Le programme doit implémenter les actions suivantes :
- Lire son propre fichier binaire en utilisant le chemin /proc/self/exe.
- Créer une archive tar contenant ce fichier, ce qui nécessite d'écrire d'abord le header puis la donnée récupérée à l'étape 1.
- Calculer l'empreinte SHA256 de ce layer.
- Construire la docker-archive finale avec :
- l'archive tar créée à l'étape 2,
- le fichier de configuration, il est composé de :
- l'entrypoint de l'image, qui sera le nom du binaire archivé dans l'étape 2,
- le hash du layer calculé à l'étape 3 ;
- le manifest.json qui définit :
- le tag, passé en argument du programme,
- le nom de l'archive ajoutée à l'étape 4.1,
- le nom du fichier de configuration créé à l'étape 4.2.
- Et enfin, écrire l'intégralité de cette docker-archive sur la sortie standard stdout.
Pour générer notre première archive auto-réplicative et passer avec succès le script de test, il suffit d'exécuter ce programme en lui passant le tag "latest" en paramètre !
Caching du layer
Examinons maintenant le résultat du script de test sur cette première solution, après avoir enlevé l'option --quiet pour avoir plus de logs.
Getting image source signatures
Copying blob f9c938e97f5c done
Copying config 9b5b3b2204 done
Writing manifest to image destination
Storing signatures
Loaded image: localhost/ocinception_c:latest
Getting image source signatures
Copying blob f9c938e97f5c skipped: already exists
Copying config 9b5b3b2204 done
Writing manifest to image destination
Storing signatures
Loaded image: localhost/ocinception_c:701bdcf28f43d13c24682fc75cad698c96c882c4441b46a2577697b1f830d343
[...]
Nous observons une différence très significative entre la sortie du premier podman load et celle du second : c'est l'indication skipped: already exists
sur la copie du layer principal de notre image, celui qui contient le binaire.
Comme expliqué précédemment, lorsque Podman charge une image, il commence par lire le manifest.json, puis le fichier de configuration associé, dans lequel est définie la liste diff_ids des hash de chaque layer. Or, Podman dispose d'un mécanisme de cache qui lui permet de gagner du temps en évitant de recopier un layer dont le hash est déjà présent dans son storage.
Dans notre cas avec un storage overlay configuré, nous voyons bien le layer en question sur notre host, dans le répertoire ~/.local/share/containers/storage/overlay/f9c938e97f5c393eb699303389f93fff1ebe08f5a39982fcf25cbeea3035c16f.
Ainsi, c'est en exploitant le cache Podman que peut être mise en oeuvre l'optimisation principale du challenge, ce qui permettra d'alléger l'archive de son entrée la plus lourde : le binaire lui-même.
Les modifications dans le code sont minimes : il faut simplement vérifier si le tag passé en argument est égal à "latest". S'il est différent, alors c'est que l'exécution ne correspond pas au premier podman load et que le layer est donc déjà présent dans le cache. Dans ce cas, nous n'ajoutons pas le layer à l'archive finale, dans laquelle il ne reste plus que les deux fichiers JSON.
Quelques améliorations
Afin de découvrir des techniques supplémentaires pour réduire encore le score, il fallait jouer avec les limites des parsers Tar et JSON utilisés par Podman. Il était aussi possible de trouver certaines idées dans le code source de Podman ou dans la documentation pour développeurs de Go.
Voici une sortie de la commande hexdump sur notre archive finale. Cette commande nous a beaucoup servie pour visualiser nos résultats et ajuster chaque octet. Cette section explique toutes les optimisations qui apparraissent dans l'illustration suivante :
$ hexdump -C final_ocinception_c.tar
00000000 6d 61 6e 69 66 65 73 74 2e 6a 73 6f 6e 00 00 00 |manifest.json...| == manifest.json header ==
00000010 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
*
00000080 00 00 00 00 32 30 31 00 00 00 00 00 00 00 00 00 |....201.........| -> Opti 3.
00000090 00 00 00 00 00 00 33 33 32 32 00 00 00 00 00 00 |......3322......|
000000a0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
*
00000200 5b 7b 22 72 65 70 6f 74 61 67 73 22 3a 5b 22 6f |[{"repotags":["o| == manifest.json data ==
00000210 63 69 6e 63 65 70 74 69 6f 6e 5f 63 3a 65 30 65 |cinception_c:e0e| -> Opti 5.
00000220 34 61 65 64 66 62 38 31 35 61 61 33 39 35 35 61 |4aedfb815aa3955a|
00000230 64 32 31 33 34 39 33 36 61 38 64 33 31 64 39 34 |d2134936a8d31d94|
00000240 62 65 39 64 31 33 32 35 65 37 65 38 34 31 65 37 |be9d1325e7e841e7|
00000250 34 65 35 33 36 38 31 66 39 61 62 39 34 22 5d 2c |4e53681f9ab94"],|
00000260 22 6c 61 79 65 72 73 22 3a 5b 22 22 5d 7d 5d 20 |"layers":[""]}] | -> Opti 4.
00000270 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 | |
00000280 20 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ...............|
00000290 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
*
00000400 00 6d 61 6e 69 66 65 73 74 2e 6a 73 6f 6e 00 00 |.manifest.json..| == config header ==
00000410 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| -> Opti 4.
*
00000480 00 00 00 00 32 30 31 00 00 00 00 00 00 00 00 00 |....201.........| -> Opti 3.
00000490 00 00 00 00 00 00 33 33 32 32 00 00 00 00 00 00 |......3322......|
000004a0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
*
00000600 7b 22 63 6f 6e 66 69 67 22 3a 7b 22 65 6e 74 72 |{"config":{"entr| == config data ==
00000610 79 70 6f 69 6e 74 22 3a 5b 22 63 22 5d 7d 2c 22 |ypoint":["c"]},"| -> Opti 2.
00000620 72 6f 6f 74 66 73 22 3a 7b 22 64 69 66 66 5f 69 |rootfs":{"diff_i|
00000630 64 73 22 3a 5b 22 73 68 61 32 35 36 3a 65 35 64 |ds":["sha256:e5d|
00000640 36 66 65 35 66 37 65 39 61 37 64 35 61 62 37 37 |6fe5f7e9a7d5ab77|
00000650 66 61 34 38 38 33 39 35 30 39 33 33 62 61 34 34 |fa4883950933ba44|
00000660 37 38 61 33 64 66 66 30 62 33 37 65 33 34 34 61 |78a3dff0b37e344a|
00000670 63 66 39 34 38 66 33 65 32 36 61 31 64 22 5d 7d |cf948f3e26a1d"]}|
00000680 7d |}| -> Opti 1.
00000681
- Normalement, une archive tar doit finir par deux blocs de 512 octets nuls, mais nous pouvons les tronquer sans que Podman ne lève d'erreur.
- Le binaire du layer principal peut être mis dans le répertoire "/bin" dans lequel podman va chercher les binaires à exécuter par défaut. Nous pouvons ainsi supprimer le '/' dans la configuration de l'entrypoint.
- Pour accepter une archive tar, Podman n'a besoin que de très peu des informations présentes dans le header de chaque fichier. Nous pouvons donc définir le minimum requis, c'est-à-dire le nom du fichier, sa taille et le checksum du header.
- C'est entre autres grâce à la technique suivante que le gagnant et XeR ont atteint d'aussi bons scores. Il s'agit d'ajouter le bon nombre d'espaces, ou un nickname de la bonne longueur pour que le manifest.json fasse exactement la même taille que le fichier de configuration. Il faut également ajouter la chaîne 'manifest.json' dans le header du fichier de configuration. Ainsi, les headers des deux fichiers sont presque identiques (à deux bytes près), et se compressent bien mieux !
- L'archive peut contenir un fichier dont le nom est une chaîne vide ! De plus, le parser de JSON laisse une chaîne vide si un des champs est absent. La combinaison de ces deux comportements permet d'omettre le champ "config" dans le manifest.
Pour finir, la compression de l'archive nous fait gagner une place non négligeable ! Podman accepte plusieurs formats différents, le meilleur algorithme dans notre cas étant Zstandard. En enlevant le checksum et le content-size, le header zstd est particulièrement petit, et en paramétrant le niveau 22 on obtient une compression remarquablement efficace.
Bruteforce du hash
Le hash de notre layer doit être présent dans la configuration, et selon les octets qui le composent il peut être plus ou moins bien compressé par zstd. Il est possible d'écrire un petit script bash qui modifie le binaire en ajoutant un compteur dans le code source. Pour chaque répétition, le script calcule le score moyen avec 10 tags aléatoires. Lors de nos tests nous avons gagné 3 octets sur la taille de l'archive compressée, après seulement quelques centaines d'itérations.
#!/bin/bash
set -e
OUTPUT=bf_results.txt
min_score=235.0
BINARY_NAME=main_exe
for ((i = 0; i < 1000000; i++)); do
if (( i % 1000 == 0 )); then
echo "[!] iteration $i" >> $OUTPUT
fi
# Update a counter to change the resulting hash
g++ -DCOUNTER=\"$i\" -static -O3 -o $BINARY_NAME best.cpp -lzstd -lcrypto -Wno-deprecated-declarations
# Compute the score over 10 executions
sum=0
loop_count=10
for ((j = 0; j < loop_count; j++)); do
random_tag=$(head -c 32 /dev/urandom | sha256sum | awk '{print $1}')
current_score=$(./$BINARY_NAME $random_tag | wc -c)
sum=$(echo "$sum + $current_score" | bc)
done
score=$(echo "scale=1; $sum / $loop_count" | bc)
# Add score to result file if it's a good one
if (( $(echo "$score <= $min_score" | bc -l) )); then
min_score=$score
echo "[>] iteration $i -> $score" >> $OUTPUT
fi
done
Exemple de solution
Voici un programme C++ qui implémente l'ensemble des optimisations présentées dans cet article. Cette solution permet d'obtenir un score moyen proche de 227.
#include <cstring>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <openssl/sha.h>
#include <vector>
#include <zstd.h>
#ifdef COUNTER
const char *counter = COUNTER; // Used for layer hash bruteforce
#endif
#ifndef NICKNAME
#define NICKNAME "c"
#endif
using namespace std;
const char null_bytes[1024] = {0};
// Calculate a sha256sum of a stream data
string calculate_sha256sum(istream &stream)
{
stringstream result;
const size_t buffer_size = 4096;
char buffer[buffer_size];
unsigned char hash[SHA256_DIGEST_LENGTH];
// Use openssl sha256 context to get data hash
SHA256_CTX sha256_ctx;
SHA256_Init(&sha256_ctx);
while (stream.read(buffer, buffer_size) || stream.gcount() > 0)
{
SHA256_Update(&sha256_ctx, buffer, stream.gcount());
}
SHA256_Final(hash, &sha256_ctx);
// Convert raw hash to hexadecimal string
for (int i = 0; i < SHA256_DIGEST_LENGTH; i++)
{
result << hex << setw(2) << setfill('0') << (int)hash[i];
}
return result.str();
}
// Add a file to the tar archive
void add_file_to_tar(
ostream &tar_file, const string &file_name, const string &file_content,
bool is_minimal_header = false, bool do_padding = true)
{
const size_t file_size = file_content.size();
// First, create header
char header[512] = {0};
strncpy(header, file_name.c_str(), 100); // File name
snprintf(header + 124, 12, "%011lo", static_cast<unsigned long>(file_size)); // File size in bytes (octal)
// Add manifest.json string in config file header to improve compression
if (file_name.empty())
{
strncpy(header + 1, "manifest.json", 16);
}
if (is_minimal_header)
{
memset(header + 124, 0x00, 8); // Add null bytes to save place
}
else
{
snprintf(header + 100, 8, "%07o", 0755); // File mode (octal)
}
// Calculate the header cheksum
memset(header + 148, ' ', 8); // Fill the checksum field with spaces before calculation
unsigned int checksum = 0;
for (int i = 0; i < 512; ++i)
{
checksum += (unsigned char)header[i];
}
snprintf(header + 148, 8, "%06o", checksum); // Insert the calculated checksum
// Add null bytes to save place
memset(header + 148, 0x00, 2);
memset(header + 154, 0x00, 2);
if (header[150] == '0')
{
header[150] = 0x00;
}
// Then, write file header and data
tar_file.write(header, 512);
tar_file.write(file_content.c_str(), file_size);
// Add padding if needed
if (do_padding)
{
tar_file.write(null_bytes, (512 - file_size % 512) % 512);
}
}
void zstd_compress(string tar_archive_data, vector<char> &compressed_data)
{
// Create a zstd compression context with the best parameters
ZSTD_CCtx *const cctx = ZSTD_createCCtx();
ZSTD_CCtx_setParameter(cctx, ZSTD_c_compressionLevel, 22); // Maximum compression level
ZSTD_CCtx_setParameter(cctx, ZSTD_c_contentSizeFlag, 0); // Disable content size in the header
ZSTD_CCtx_setParameter(cctx, ZSTD_c_checksumFlag, 0); // Disable checksum in the header
// Perform the compression
const size_t compression_buff_size = ZSTD_compressBound(tar_archive_data.size());
compressed_data.resize(compression_buff_size);
const size_t compressed_size = ZSTD_compress2(
cctx, compressed_data.data(), compression_buff_size, tar_archive_data.data(), tar_archive_data.size());
compressed_data.resize(compressed_size);
ZSTD_freeCCtx(cctx);
}
int main(int argc, char *argv[])
{
// Check tag argument
if (argc < 2)
{
cerr << "Usage: " << argv[0] << " <tag>" << endl;
return 1;
}
const string tag_string = argv[1];
const bool is_initial_load = tag_string == "latest";
const string file_name = NICKNAME;
const string config_name = "";
const string manifest_name = "manifest.json";
const string layer_string = is_initial_load ? "layer.tar" : config_name;
ifstream inFile("/proc/self/exe", ios::binary); // Read self program binary file
vector<char> program_data((istreambuf_iterator<char>(inFile)), istreambuf_iterator<char>());
inFile.close();
// Add self binary file in tar archive, inside "bin" directory
ostringstream hash_stream;
add_file_to_tar(hash_stream, "bin/" + file_name, string(program_data.data(), program_data.size()));
hash_stream.write(null_bytes, 512 * 2); // Write two empty blocks to end the tar archive
// Calculate resulting tar archive checksum, in order to include it in config content
istringstream input_stream(hash_stream.str());
const string layer_checksum = calculate_sha256sum(input_stream);
// Create files for the new archive
const string config_content =
"{\"config\":{\"entrypoint\":[\"" + file_name +
"\"]},\"rootfs\":{\"diff_ids\":[\"sha256:" + layer_checksum + "\"]}}";
const string manifest_content =
"[{\"repotags\":[\"ocinception_" + file_name +
":" + tag_string + "\"],\"layers\":[\"" + layer_string + "\"]}]" +
" "; // Add padding to match config file header
ostringstream new_tar_stream;
if (is_initial_load) // No need to add main layer if it's already in podman cache
{
add_file_to_tar(new_tar_stream, layer_string, hash_stream.str());
}
// Add config and manifest files in final tar archive
add_file_to_tar(new_tar_stream, manifest_name, manifest_content, true);
add_file_to_tar(new_tar_stream, config_name, config_content, true, false);
vector<char> compressed_data;
zstd_compress(new_tar_stream.str(), compressed_data); // zstd best compression
// Write the compressed data to stdout
cout.write(compressed_data.data(), compressed_data.size());
return 0;
}
Ce code peut être compilé avec la commande suivante, où la valeur de COUNTER a été déterminée grâce au script de bruteforce presenté précédemment :
g++ -static -O3 -DNICKNAME=\"c\" -DCOUNTER=\"424\" -o best_solution best.cpp -lzstd -lcrypto -Wno-deprecated-declarations
Pour générer la solution attendue, le binaire doit être exécuté avec l'argument "latest" :
./best_solution latest > ocinception_c.tar
Conclusion
Merci encore à tous les participants !
En partant d’une archive initiale de plusieurs mégaoctets, nous avons réussi à réduire sa taille à seulement 227 octets. Ce résultat a été obtenu en tirant parti du fonctionnement interne de Podman lors du chargement d’une image. Cela a été réalisé à différents niveaux : exploitation du parser tar pour supprimer les parties superflues, réduction des JSON au minimum, et utilisation adéquate du caching des layers ainsi que de la compression.
Si après avoir lu cet article vous avez des idées ou des suggestions pour améliorer davantage la solution, n'hésitez pas à nous en faire part à l'adresse summer-challenge@synacktiv.com ! Nous remettons en jeu le clavier Keychron, il sera remporté par la première personne qui réussira à descendre sous la barre symbolique des 200 octets 🎁