Od zeszłego tygodnia prawie codziennie prowadzę "wirtualne warsztaty dla początkujących z jakości kodu" - w dużym skrócie: analizuje i sugeruje rzeczy do poprawienia / zrobienia inaczej w kodzie, który otrzymuje od widzów. Wczorajszy (#6) odcinek był o dość krótkim i ciekawym kodzie w C++, który między innymi zapisywał ("manualnie") plik BMP. Ponieważ sam nagłówek BMP był tworzony/trzymany bezpośrednio w tablicy bajtów - co nie jest specjalnie czytelne - zasugerowałem użycie struktury. To z kolei spotkało się z zaskoczeniem jednego z widzów (w komentarzach pod wideo) z uwagi na dopełnienie (padding) / wyrównanie pól (alignment). Delikatnie rozbudowaną wersję mojej odpowiedzi zamieszczam poniżej.
Klasyczną metodą odczytu/zapisu nagłówków w C/C++ są/były zapisy/odczyty całych instancji struct'ów. A co z paddingiem? Ten się po prostu wyłącza dla danej struktury za pomocą jednego z dwóch sposobów:
- __attribute__ ((__packed__))
- #pragma pack(push, 1) i później #pragma pack(pop)
Przykładowy kod:
#include <cstdio>
#include <cstdint>
struct __attribute__((__packed__)) SomeFileHeader {
uint8_t version;
uint16_t width;
uint16_t height;
uint8_t compression;
};
int main() {
SomeFileHeader header;
header.version = 2;
header.width = 123;
header.height = 78;
header.compression = 0;
FILE *f = fopen("out.bin", "wb");
if (f == nullptr) {
perror("Failed to create file:");
return 1;
}
fwrite(&header, 1, sizeof(header) /* 6 bytes */, f);
fclose(f);
}
Formalnie dowolny obiekt w C/C++ może zostać odczytany jako seria bajtów (unsigned char), oraz może zostać odtworzony z serii bajtów – standardy C/C++ zawierają porozrzucane informacje o tym – więc to nie jest problemem. Drobny druk dotyczy oczywiście wskaźników i czasu życia obiektów na które te wskazują, ale ten problem nie dotyczy nagłówków plików, które pointerów nie mają - co najwyżej offsety.
Powyższe podejście oczywiście nie działa w przypadku:
- Nagłówków o nie-stałych wielkościach (np. większość struktur w formacie ZIP nie ma stałych wielkości).
- Użycie typów danych, które nie są natywne dla C/C++ i danej architektury (np. pola kodowane Big Endian podczas gdy architektura jest Little Endian, albo po prostu inne kodowania pól - np. LEB128 zamiast signed/unsigned intów).
Natomiast na streamie nie chodziło mi o bezpośredni zapis struktury, tylko o wrzucenie wszystkich pól do struktury dla czytelności, a potem zapis pola po polu. Zazwyczaj tworzy się do tego zestaw metod/funkcji wspomagających typu write_int8, write_int16, write_int32, etc - takie patterny można znaleźć w wielu encoderach (lub analogiczne z "read" w wielu parserach).
Najlepiej byłoby opakować to w klasę typu "BMPInfoHeader", która oprócz pól tego nagłówka będzie miała również metodę typu "build", "bake", "make", "serialize", "toBinaryData" (zwał jak zwał), która zwróci (albo wypełni podany) vector<uint8_t> (lub ananalogiczną strukturę danych) na podstawie zawartości pól. Alternatywnie może mieć metodę "write", która dostanie wskaźnik do pliku i pozapisuje pola do niego (korzystając ze wspomnianych write_int8, ...) - tu by można się zastanowić czy to nie byłaby zbytnia specjalizacja klasy, ale to kwestia projektu.
By the way...
On 22nd Nov'24 we're running a webinar called "CVEs of SSH" – it's free, but requires sign up: https://hexarcana.ch/workshops/cves-of-ssh (Dan from HexArcana is the speaker).
Przykładowy kod:
#include <cstdio>
#include <cstdint>
#include <vector>
#include <cassert>
// Helper class (normally it would go to a different file).
// It's a bit simplified too.
class HelperBinaryWriter {
private:
std::vector<uint8_t> data_;
public:
const std::vector<uint8_t>& get_data() const {
return data_;
}
void write_uint8(uint8_t v) {
data_.push_back(v);
}
void write_uint16le(uint16_t v) {
write_uint8((uint8_t)v);
write_uint8((uint8_t)(v >> 8));
}
};
class SomeFileHeader {
public: // Or private + getters/setters if you prefer.
static const size_t HEADER_SIZE = 1 + 2 + 2 + 1;
uint8_t version;
uint16_t width;
uint16_t height;
uint8_t compression;
std::vector build() const {
HelperBinaryWriter writer;
writer.write_uint8(version);
writer.write_uint16le(width);
writer.write_uint16le(height);
writer.write_uint8(compression);
auto data = writer.get_data();
assert(data.size() == SomeFileHeader::HEADER_SIZE);
return data;
}
};
int main() {
SomeFileHeader header;
header.version = 2;
header.width = 123;
header.height = 78;
header.compression = 0;
auto data = header.build();
FILE *f = fopen("out.bin", "wb");
if (f == nullptr) {
perror("Failed to create file:");
return 1;
}
fwrite(data.data(), 1, data.size(), f);
fclose(f);
}
Powyższy pomysł można by zaimplementować również na kilka innych sposobów, ale ostatecznie powinniśmy otrzymać czytelny kod, który przy okazji jest przenośny pomiędzy platformami/kompilatorami, a także całkiem łatwy do rozszerzenia.
Dodatkowe materiały:
- Artykuł „Diabeł tkwi w szczegółach: C/C++, cz. 2” – sekcja „Padding”.
- Książka "Zrozumieć Programowanie" – rozdział 11 „Pliki binarne i tekstowe”, str 394.
- I dodatkowo monografia j00ru „Detecting Kernel Memory Disclosure with x86 Emulation and Taint Tracking” – rozdział 2.1.2 „Structure alignment and padding bytes”.
Comments:
Add a comment: