We constantly work with different datasets that we normalize into our data lake. Recently we received CronosPro database files (CroBank.dat, CroIndex.dat, and CroStru.dat) that were thought to be broken because existing tooling could not parse them correctly.
Anyone who has worked with the CronosPro format, knows that its raw datafiles are notoriously difficult to convert into machine-readable formats such as CSV. A good amount of work has been put into the Cronos dump converter Cronodump alephdata/cronodump, but due to the version specific nuances of the database file structures, it doesn’t work 100% of the time. For the purpose of tackling this problem, we employed Codex for analyzing the dump structure and improved upon the Cronodump codebase to fix the parsing of the allegedly broken database.
Cronodump converter could read much of the format, but it could not decode this particular dump correctly. We had to understand how the files fit together, recover an obfuscated schema, fix several parser assumptions, and validate that parsed values still appeared under the correct schema columns.
This article explains that process from the beginning with no prior knowledge of Cronos assumed.
What is Cronos? #
Cronos, also known as CronosPro, is a proprietary desktop database and information-management system. It has historically been used by organizations in Russia and other post-Soviet countries to build registries, searchable archives, document collections, and internal information systems.
A Cronos installation uses slightly different terminology from a conventional database:
| Conventional term | Cronos term |
|---|---|
| Database | Bank |
| Table | Base |
| Record | Record |
| Field | Field |
| Record ID | System Number |
A Cronos database is normally represented by several related binary files:
| Files | Purpose |
|---|---|
CroStru.dat and CroStru.tad |
Database structure: tables, fields, forms, and other definitions |
CroBank.dat and CroBank.tad |
Actual records |
CroIndex.dat and CroIndex.tad |
Search indexes |
CroSys.dat and CroSys.tad |
Information about databases known to a Cronos installation |
| Additional files | Forms, formulas, dictionaries, and embedded documents |
The .dat files contain data. Their corresponding .tad files act as directories, telling the software where records are located inside each .dat file.
A useful mental model is:
CroStru -> explains what the database looks like
CroBank -> contains the actual rows
CroIndex -> helps Cronos find those rowsThis distinction became central to the recovery. The record data itself was readable, but the schema needed to interpret it was protected.
What do we mean by “normalization”? #
In this article, normalization does not mean relational database normalization into first, second, or third normal form.
Here it means converting a proprietary binary database into a portable and reviewable representation:
Cronos binary files
|
v
decoded tables and fields
|
v
correctly typed and aligned values
|
v
UTF-8 CSV filesThe goal was not merely to extract strings. The result needed to preserve the relationship between:
- Tables and records
- Column names and values
- Dates and their meanings
- Text and its original character encoding
- Internal field positions
- Embedded file references
A CSV containing readable values under the wrong headers would be worse than an obvious error because it could look valid while being semantically corrupted.
Choosing an existing parser #
We started with the open-source Cronodump package which provides two main commands:
crodumpinspects Cronos files and prints their internal structures.croconvertexports a database to CSV, PostgreSQL SQL, or HTML.
For a typical dump, conversion can be as simple as:
croconvert --csv /path/to/cronos-dumpInternally, the package performs several jobs:
- It reads the
.tadfiles to locate records. - It retrieves the corresponding bytes from the
.datfiles. - It decompresses records when necessary.
- It decodes protected records using a KOD table.
- It reads the schema from
CroStru. - It uses that schema to divide
CroBankrecords into fields. - It writes the resulting tables to CSV.
The repository documentation explicitly notes that parts of the Cronos format remain incompletely reverse-engineered. The parser supports many databases, but unusual version-specific details can still require investigation.
The first failure #
The converter failed while reading CroStru.dat.
This meant we could not yet answer basic questions such as:
- How many tables are present?
- What are their names?
- How many fields does each table contain?
- Which field is a name, date, identifier, or file reference?
- Where does each value belong in the output?
The large CroBank.dat file contained the actual records, but without a valid schema it was effectively a stream of values separated by binary markers.
The situation looked like this:
CroStru.dat -- decoding failed --> no trustworthy column definitions
|
v
CroBank.dat --------------------> values cannot be interpreted safelyBefore attempting to crack anything, we inspected the headers of all major files.
The dump used the Cronos 01.11 format, associated with Cronos v4. The component flags revealed an important detail:
CroStruwas KOD-encoded.CroBankwas compressed but not KOD-encoded.CroIndexwas compressed but not KOD-encoded.
This reduced the scope of the problem considerably.
We did not need to decode tens of gigabytes of protected record data. We only needed to recover the relatively small schema file. Once the schema was readable, the ordinary parser could interpret the much larger record file.
Compression and protection are different layers #
It is useful to separate two concepts that can otherwise become confusing.
Compression changes data so that it occupies less space:
original bytes -> compression -> smaller encoded bytesKOD protection changes byte values using a substitution table:
original bytes -> position-dependent substitution -> protected bytesA file can be:
- Neither compressed nor KOD-encoded
- Compressed only
- KOD-encoded only
- Both compressed and KOD-encoded
The files in this dump did not all use the same combination.
That mattered because one of cronodump’s cracking methods assumes it can learn the KOD table from predictable bytes in compressed CroBank and CroIndex records. Here, those files were not KOD-encoded at all, so that assumption did not apply.
What is a KOD table? #
Cronos v4 and later can protect databases by modifying a 256-entry byte-substitution table called the KOD table or KOD S-box.
At the simplest level, a substitution table says:
encrypted byte 0x00 -> decoded value X
encrypted byte 0x01 -> decoded value Y
...
encrypted byte 0xFF -> decoded value ZCronos adds another complication: decoding also depends on the byte’s position and the record number.
Conceptually, the algorithm is:
plaintext[i] = KOD[ciphertext[i]] - i - record_number (mod 256)Where:
ciphertext[i]is the stored byte.KOD[...]performs a substitution.iis the position inside the record.record_numbercontributes another shift.- Arithmetic wraps around at 256.
The practical consequence is that being off by even one byte changes the decoding of everything that follows.
This later explained why one schema record remained unreadable even after we recovered a plausible KOD table.
What the existing cracking tools do #
cronodump includes two relevant recovery options:
crodump --strucrack ...
crodump --dbcrack ...They use statistical properties of Cronos files rather than brute-forcing a password.
strucrack
#
Binary schemas contain many repeated values, especially zero bytes used in lengths, flags, padding, and integers.
strucrack groups encrypted bytes by their effective position and assumes that the most common result probably represents plaintext zero. With enough schema data, this can reveal much of the KOD table.
dbcrack
#
Compressed records have recognizable headers. In suitable databases, predictable bytes in those headers can be used to infer KOD mappings from CroBank and CroIndex.
Both are heuristics. They work when the source contains enough of the patterns they expect.
In our case:
CroStrucontained too little evidence forstrucrackto recover every entry reliably.CroBankandCroIndexwere not KOD-encoded, sodbcrackwas not applicable.
There was also a smaller software bug: the cracking paths created a temporary argument object without a required compact property. Adding it allowed the heuristics to run:
cargs.compact = args.compactThey still did not recover a valid table automatically, but we had moved from a program error to the actual data-recovery problem.
Related upstream work #
The cronodump repository contains an unmerged branch associated with pull request #22, named erdgeist-strucrack-ambigous-kods.
That work addresses the same broad weakness: strucrack may not have enough evidence to determine every KOD entry with confidence.
The pull request adds an interactive recovery workflow:
- Track confidence for each inferred KOD entry.
- Mark entries that remain unresolved.
- Detect duplicate mappings.
- Print partially decoded schema data.
- Search for likely strings such as
BankNameandUSERINFO. - Let the operator supply individual corrections.
- Let the operator provide known text at a particular record position.
- Improve the way
strucrackis called fromcroconvert.
The intended process is approximately:
run automatic heuristic
|
v
inspect partially decoded text
|
v
recognize likely words
|
v
provide known-plaintext hints
|
v
resolve the remaining KOD entriesOur situation still required additional changes because KOD ambiguity was only the first problem. The upstream branch did not appear to address:
- A Cronos v4 inline-record header that shifted decoding.
- Hidden physical fields inside records.
- Incorrect handling of documented text field types.
We therefore used the same general idea, but automated the assignment differently.
Building a better statistical reference #
A smaller readable Cronos component was available alongside the main dump. The cronodump repository also included a test database with known field types.
These gave us examples of what valid decoded Cronos structures look like.
We did not copy their schemas and assume the unknown database was identical. Instead, we used them to estimate general byte frequencies in Cronos schema records.
For example, valid schema data tends to contain:
- Many zero bytes
- Small binary integers
- Length-prefixed names
- Repeated structure markers
- ASCII property names
- Windows-1251 text
- Repeated field-definition layouts
For every encrypted byte in the unknown schema, we knew:
- Its ciphertext value
- Its byte position
- Its record number
For every possible KOD mapping, we could calculate the plaintext byte it would produce and score how plausible that byte was according to the reference distribution.
This produced a score table:
possible KOD output
0x00 0x01 0x02 ... 0xFF
cipher 0x00 score score score ... score
cipher 0x01 score score score ... score
...
cipher 0xFF score score score ... scoreWhy this is an assignment problem #
A KOD table must be a permutation.
That means:
- Every ciphertext byte has exactly one mapping.
- No two ciphertext bytes can map to the same KOD value.
- Every value from
0x00to0xFFmust appear once.
We could not simply choose the highest-scoring value independently for each byte. Several ciphertext bytes might select the same output, producing an invalid table.
Instead, we needed to choose the best overall set of mappings while enforcing uniqueness.
This is a classic assignment problem. We solved it with SciPy’s implementation of the Hungarian algorithm:
from scipy.optimize import linear_sum_assignment
rows, columns = linear_sum_assignment(-scores)linear_sum_assignment minimizes cost, so the scores were negated to select the maximum-likelihood assignment.
The result was a complete 256-byte KOD permutation.
At this point we had a candidate key, not yet a proven one.
Structural validation instead of “it looks readable” #
Readable output alone is weak evidence. A wrong substitution can accidentally produce letters, digits, or familiar fragments.
We validated the candidate KOD table against the expected Cronos structure:
- Did records begin with valid type markers?
- Could length-prefixed names be read without running past the record?
- Did known keys such as
Bank,BankId, andBankNameappear? - Did
BaseNNNentries point to valid table definitions? - Were table IDs and field counts plausible?
- Did referenced schema records decode consistently?
- Did Windows-1251 text decode into sensible field names?
- Was the final KOD table a true 256-byte permutation?
All but one schema records passed these checks, which led to the next discovery.
The first schema record had an extra header #
The first CroStru record used a Cronos v4 flag value of 0x08.
Its stored data began with a 12-byte extent header:
uint64 extent_offset
uint32 extent_lengthThe parser treated any nonzero flag as meaning that the record bytes could be decoded directly. It therefore sent the header and payload together to the KOD decoder.
Recall that KOD decoding depends on the byte position:
plaintext[i] = KOD[ciphertext[i]] - i - record_numberIncluding twelve header bytes did not merely produce twelve unwanted characters. It changed the position i for every payload byte.
The whole record was consequently decoded with a position offset of twelve.
The situation was:
stored record:
[12-byte extent header][encrypted schema payload]
parser assumed:
[ encrypted schema payload ]
^ decoding starts here
correct interpretation:
[skip this header][encrypted schema payload]
^ decoding starts hereThe parser was adjusted to recognize the v4 0x08 representation, read the extent header, and decode only the declared payload:
elif not flags or (self.isv4() and flags == 0x08):
if self.use64bit:
next_offset, extent_length = struct.unpack("<QL", data[:12])
payload_offset = 12After removing the header, the record began with:
03 04 42 61 6e 6bAt a byte level, that means:
03 schema-record marker
04 length of the following name
42 61 6e 6b ASCII text "Bank"This was strong structural evidence that both the extent handling and the recovered KOD table were correct.
The key lesson was that a decoding failure does not always indicate a bad key. Sometimes the right decoder is being applied at the wrong boundary.
Recovering the tables #
Once CroStru decoded correctly, the parser could reconstruct the logical database structure:
database definition
|
+-- table definition
| |
| +-- field name
| +-- field type
| +-- physical position
| +-- maximum length
|
+-- another table definition
|
+-- forms and other metadataThe recovered schema contained an ordinary data table and a related file table.
This allowed the converter to move from anonymous byte sequences to records with named columns.
The first CSV export was then started—but sampling it revealed another problem.
Text was exported as hexadecimal #
The CSV had the correct number of columns, but many text values looked like this:
c1 c8 d7 20 c2 ce cb ce c4 c8 cc c8 d0These were not corrupt bytes. They were Windows-1251 text represented as hexadecimal.
Cronos commonly stores Cyrillic text using Windows-1251. A previous safety change in the parser preserved unknown field types as hex rather than decoding arbitrary binary data as text. That was sensible for undocumented types, but it had also affected known textual types.
The fix was to handle documented types 1, 2, and 3 explicitly:
elif self.typ in (1, 2, 3):
self.content = data.rstrip(b"\x00").decode("cp1251", "ignore")The conversion path then became:
Cronos CP1251 bytes
|
v
Python Unicode text
|
v
UTF-8 CSVUnknown binary types continued to be exported losslessly as hexadecimal.
After this fix, names and other textual values were readable, but some appeared under the wrong headers.
Why readable values were still not enough #
Consider a simplified table definition:
Field A has physical position 2
Field B has physical position 4
Field C has physical position 7The original parser assumed that because these were the three visible definitions, they corresponded to the first three stored values:
Field A <- stored value 1
Field B <- stored value 2
Field C <- stored value 3That assumption was wrong.
Cronos records can contain internal or hidden fields that do not have ordinary visible definitions. The field definition’s idx2 value specifies its actual position in the serialized record.
The correct mapping was:
Field A <- stored value 2
Field B <- stored value 4
Field C <- stored value 7Values 1, 3, 5, and 6 still had to be consumed even though they were not exported.
Without accounting for those gaps, the CSV looked superficially valid:
- Names were readable.
- Dates resembled dates.
- Identifiers resembled identifiers.
But the values were shifted under unrelated columns.
This is exactly the kind of error that makes output sampling essential.
Respecting physical field positions #
The record parser was changed to track the current physical field position.
Before reading each visible definition, it consumes hidden fields until it reaches the definition’s idx2 position:
source_index = 1
for field_definition in table_definition[1:]:
while source_index < field_definition.idx2 and not reader.eof():
read_field_data(reader)
source_index += 1
value = read_field_data(reader) if not reader.eof() else b""
source_index += 1Cronos also supports complex field values prefixed by 0x1b, so field consumption had to preserve that behavior:
def read_field_data(reader):
if reader.testbyte(0x1b):
reader.readbyte()
size = reader.readdword()
return reader.readbytes(size)
return reader.readtoseperator(b"\x1e")After this change, bounded samples showed the expected relationships:
identifier column -> identifier-like value
name column -> human name
address column -> address
date column -> valid date
category column -> category valueResult #
The final CSV conversion combined:
- The open-source
cronodumppackage - Ideas related to the ambiguous-KOD work in pull request #22
- Statistical KOD reconstruction
- Global assignment using the Hungarian algorithm
- Correct Cronos v4 inline-extent handling
- Windows-1251-to-Unicode conversion
- Physical field-position tracking
- Bounded semantic validation