While working on an OT project, we looked into TIA Portal1 project files to extract more information about changes, especially timestamps to be able to reconstruct a timeline. The TIA Portal (Totally Integrated Automation Portal) allows to create and upload programs for PLC (Programmable Logic Controller) devices often used in the OT (Operational Technology) landscape. Some attacks are able to find the workstation with the TIA Portal and manipulate the project to reprogram the PLCs. To be able to reconstruct the timeline of these changes we wanted to be able to read the timestamps of events from the TIA project files.
Prior research23 managed to extract some timestamps, but many of those visible in the TIA UI were still missing. This led us to dig deeper into the plf file format and develop a parser capable of extracting more data and timestamps.
Prior Research
We did not want to repeat work that others had already done. A review of prior research showed that it focused on data extracted via strings, which only yields the DownloadLog timestamps.
End Of File
Prior research4 identified ##CLOSE## and $$COMMIT$ blocks near the end of the file, likely within two separate blocks. Our first step was to locate these blocks and determine whether they could be used to segment the file.

Bytes at the end of a PLF file.
Both the ##CLOSE## and $$COMMIT$ strings are visible, and we can begin identifying patterns. A block appears to end with 0xFF followed by 32 bytes, later identified as the SHA256 hash of the preceding block’s content. This in turn revealed the start of each block: a little-endian 4-byte integer specifying the block length, excluding the SHA256 but including the length field itself and the 0xFF marker. This pattern begins at file offset 98 and repeats consistently throughout the file, allowing us to segment it.
Each block likely starts with a common header that includes at least the block size field. Scanning all blocks, we find a consistent 16-byte non-null region at offset 28, which appears to be a UUID. After the UUID, the commit and close blocks contain an additional length field specifying the distance from the end of the length field to the 0xFF marker. This pattern does not hold for all blocks, however.
Looking more closely at the ##CLOSE## and $$COMMIT$ strings, we find a 0x0A byte preceding each one. This appears to be a length prefix, especially since the four 0xFF bytes before it likely represent the integer -1. However, the strings themselves are 9 bytes long, not 10. The length could account for a null terminator, which is present after ##CLOSE## but not after $$COMMIT$, suggesting the length field includes its own size. Limiting strings to 255 bytes would also be overly restrictive. Deeper in the file, we find longer strings prefixed with a length encoded as a variable-length integer: if the high bit is set, that bit is discarded and the next byte is read. The following Python function demonstrates how to decode such integers.
def readVarIntLE(data, off=0):
num = 0
s = 0
while True:
b = data[off]
off += 1
num |= (b&0x7F) << s
s += 7
if (b & 0x80) == 0:
return num, offSystem Blocks
Returning to the block header, we notice that every block with a length field after the UUID carries a type value of 0x700yy (highlighted in yellow), indicating whether a length follows the UUID. Among these, the block of type 0x70000 stands out: it is unusually large and exhibits high entropy. Attempting a zlib decompression of the data between the length field and the 0xFF marker reveals XML content.
<ns0:MetaInfo xmlns:ns0="http://www.siemens.com/Automation/2004/04/ObjectFrame/Meta" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.siemens.com/Automation/2004/04/ObjectFrame/Meta Automation-ObjectFrame-MetaData-v1.0.xsd">
<ns0:Package name="Siemens.Automation.CommonModel.Printing" id="0x00030001" version="5.0.0.0" packageContainer="Siemens.Automation.Portal" packageContainerVersion="2.0.0.0.0" resourceAssembly="Siemens.Automation.CommonServices.Printing" interfaceAssembly="Siemens.Automation.CommonServices.Printing">
<ns0:RequiredPackage package="Siemens.Automation.DomainModel" version="5.0.0.0" />
<ns0:Namespace name="Siemens.Automation.CommonModel.Printing">
<ns0:Enumeration name="Categories" id="0x00035006" private="false">
<ns0:Description>A predefined selectable by the user Categories, which are specific to the type of the object.</ns0:Description>
<ns0:Constant name="Text" value="0" />
...
</ns0:Enumeration>
...
<ns0:AttributeSet name="IFormEntryAttributes" id="0x00033002" private="false" persistent="true">
<ns0:Attribute name="Height" id="0" type="xs:float" access="readwrite" private="false" />
...
</ns0:AttributeSet>
...
<ns0:ObjectType name="CoverPageDescription" id="0x00031006" private="false" memberOption="isObject">
<ns0:Base ref="Siemens.Automation.CommonModel.Printing.FormPage" />
<ns0:Base ref="Siemens.Automation.DomainModel.SimpleFolderElementData" />
<ns0:Implements ref="Siemens.Automation.ObjectFrame.ICoreAttributes">
<ns0:Attribute name="Name" constant="false">
<ns0:PropertyAccess />
...
</ns0:Attribute>
</ns0:Implements>
...Cross-referencing the block types outside the 0x700yy range with this XML file reveals an ObjectType definition with the same id for each of them.
ObjectType
The key to parsing an ObjectType lies in understanding its AttributeSets and Implements tags. To work through this, we start with a simple example: the ChangeListData object.
<ns0:AttributeSet name="IChangeListDataRespository" id="0x0001300b" private="false" persistent="true">
<ns0:Description>Attribute set stores entries of change list</ns0:Description>
<ns0:Attribute name="Compiler" id="0" type="xs:string" access="readwrite" private="false">
<ns0:Constraint type="Length" value="128" />
</ns0:Attribute>
<ns0:Attribute name="Value" id="1" type="pe:XmlT" access="readwrite" private="false" />
</ns0:AttributeSet>
<ns0:ObjectType name="ChangeListData" id="0x0001100d" private="false" memberOption="isObject">
<ns0:Description>Storage object for a change list</ns0:Description>
<ns0:Base ref="Siemens.Automation.ObjectFrame.CoreObject" primary="true" />
<ns0:Implements ref="Siemens.Automation.ObjectFrame.IChangeListDataRespository" />
<ns0:Relation name="CoreTarget" id="0x0001201e" private="false" behaviourType="Siemens.Automation.ObjectFrame.InverseWeakDeepBehT" cardinality="1">
<ns0:Description>Relation ChangeList to Container</ns0:Description>
<ns0:Target ref="Siemens.Automation.ObjectFrame.CoreTarget" />
<ns0:Inverse ref="ChangeList" />
</ns0:Relation>
<ns0:BusinessLogic ServiceId="Siemens.Automation.ObjectFrame.ChangeList.Data.ChangeListDataBL">
<ns0:Hook type="Deleting" />
</ns0:BusinessLogic>
</ns0:ObjectType>We located and extracted a matching instance from the plf file. It begins with the standard header, followed by a 4-byte integer that appears to be an offset (highlighted in red). Following this offset from the start of the block leads to the length field (highlighted in blue), which specifies the size of this segment, including itself, up to the 0xFF marker. Two additional offsets follow, this time relative to the blue length field: the first points to the varint-prefixed string, the second to the byte immediately after it. Comparing this layout with the definition of ChangeListData’s single implemented interface, IChangeListDataRespository, confirms the match: the Compiler attribute is a string, aligning with the string we found, while the Value is an XML type, matching the XML payload that follows.

Single simple object.
This clarifies the overall structure. After the header comes a list of offsets pointing to each implemented attribute set’s data. Within each attribute set’s segment, values are serialized in attribute order. For variable-length attributes, an integer offset points to the actual content, such as strings or XML data.
Several nuances require careful handling. First, not all attributes are serialized, and those marked as constant are omitted. Second, an interface may be inherited through multiple base classes yet written only once; determining whether its attributes should be serialized involves non-trivial logic when an attribute is constant in one implementation but not in another. Third, the list of implemented attribute sets must be sorted by name. Finally, so-called “expando” attribute sets store their attribute definitions and types in a system object rather than in the XML.
Putting it all together, we were able to parse most of the file. The XML also defines Relation tags, which come into play when an attribute references another ObjectType. Rather than embedding the full object, a link is stored instead. We did not explore the exact details of these relations, as our primary goal was timestamp extraction.
Tool
We combined all of this into a Python program tia-parser that extracts timestamps, such as those from the Siemens.Automation.ObjectFrame.ICoreAttributes attribute set, into a Sleuth Kit body file, or outputs all parsed data as line-delimited JSON.
Run the parser with
python3 ./tia-parser.py path/to/project/PEData.plf -j out.jsonto generate JSON output, or
python3 ./tia-parser.py path/to/project/PEData.plf -b out.bodyfor a Sleuth Kit body file.
Circling back to the DownloadLog mentioned at the beginning: the JSON output of a parsed plf file contains multiple such XML entries, not just the one visible via strings. In the JSON output, these appear within Siemens.Automation.DomainModel.FolderData objects, under the Siemens.Automation.ObjectFrame.ICoreExpandoAttributes attribute set, in the DownloadLog attribute.
Conclusion
This exploration of the TIA Portal project file format shows one approach of understanding an unknown file format by searching for patterns and looking common serialization methods used in save files. The gained insights were used to implement a parser tool for the file format which should help to extract a timeline of events performed on the project which might be helpful in case of incidents.
While most parts of the file format can be extracted, some aspects are currently not supported such as relations between objects and encrypted projects.
Cheers! Nils
If you’re interested in work like this, we share research of this kind regularly at TROOPERS, our annual security conference in Heidelberg. The event brings together researchers, practitioners, and defenders for a week of talks and trainings on offensive and defensive security.