Jump List Parser
Volver al blog

Parsear AutomaticDestinations en Python a las malas

2026-06-154 min de lectura

JLECmd ya parsea estos archivos correctamente, así que la razón honesta para escribir el tuyo es la integración: quieres que los eventos de Jump List fluyan a un pipeline, un esquema propio, una herramienta de triage o un notebook, sin invocar un binario de Windows y raspar CSV. Esa es una buena razón. "Porque no me fío de la herramienta existente" no lo es, y deberías validar lo que construyes contra JLECmd antes de fiarte de ello.

Este es el camino a través del archivo, en Python, con las partes en las que la gente tropieza de verdad señaladas. La disposición de campos a nivel de byte vive en el artículo sobre el formato de archivo automaticDestinations; aquí va de llegar a ello en código.

El contenedor es OLE, así que empieza por ahí

Un archivo .automaticDestinations-ms es un archivo compuesto OLE (el viejo formato de almacenamiento estructurado / CFBF). Ese solo hecho decide tu primera dependencia. Usa olefile:

import olefile

path = "1b4dd67f29cb1962.automaticDestinations-ms"
ole = olefile.OleFileIO(path)

# Flujos: los flujos numerados contienen un LNK cada uno, más un flujo "DestList".
for entry in ole.listdir():
    print(entry)        # p. ej. ['1'], ['2'], ['DestList']

Los flujos numerados (1, 2, 3, ...) contienen cada uno un único acceso directo de Windows, la misma estructura en disco que un .lnk independiente. DestList es el índice que los une y lleva los metadatos por los que realmente vienes: el nombre de host, el contador de accesos, la hora del último acceso, el estado de anclaje y a qué flujo numerado apunta cada entrada.

Primera trampa, antes de cualquier parsing: en un sistema activo el archivo está bloqueado por explorer.exe. OleFileIO fallará o leerá un archivo parcial. Trabaja desde una imagen forense, una Volume Shadow Copy, o un robocopy /b de un snapshot montado. Nunca apuntes esto al perfil activo y te fíes del resultado.

Decodificar los flujos LNK

No escribas el parser de LNK a mano salvo que disfrutes del dolor. pylnk3 lee la estructura del acceso directo incluida la ruta objetivo y el TrackerDataBlock:

import io, pylnk3

def read_lnk_stream(ole, stream_name):
    raw = ole.openstream(stream_name).read()
    return pylnk3.parse(io.BytesIO(raw))

lnk = read_lnk_stream(ole, "1")
print(lnk.path)             # ruta objetivo, cuando está presente

Sé defensivo aquí. Algunos flujos llevan accesos directos sin bloque LinkInfo (objetivos solo de red, o elementos direccionados puramente por una lista de ID de elementos de shell), así que lnk.path puede estar vacío aunque la entrada sea perfectamente válida. Captura el caso vacío y recurre a la ruta relativa o a la lista de ID de elementos de shell en vez de descartar la entrada.

Recorrer la DestList

DestList es una cabecera seguida de entradas de forma fija, cada una terminando en una ruta UTF-16 de longitud variable. Los campos que quieres por entrada son el ID de entrada, el nombre de host NetBIOS, el FILETIME del último acceso, el estado de anclaje y el número de flujo objetivo. Esbozo:

import struct

def parse_destlist(raw):
    # Cabecera: versión + contadores. La versión gobierna la disposición de las entradas.
    version, n_entries = struct.unpack_from("<II", raw, 0)
    offset = 32  # tamaño de cabecera; confirmar contra la referencia de formato
    entries = []
    for _ in range(n_entries):
        # Los offsets de campos de abajo son ilustrativos. Lee el artículo
        # de formato y confírmalos contra tu muestra antes de fiarte de ellos.
        hostname = raw[offset+72:offset+88].split(b"\x00")[0].decode("ascii", "replace")
        entry_id, = struct.unpack_from("<I", raw, offset+88)
        filetime, = struct.unpack_from("<Q", raw, offset+100)
        path_chars, = struct.unpack_from("<H", raw, offset+128)
        path = raw[offset+130:offset+130+path_chars*2].decode("utf-16-le")
        entries.append({
            "entry_id": entry_id,
            "hostname": hostname,
            "filetime": filetime,
            "path": path,
        })
        offset += 130 + path_chars*2
    return entries

He dejado los offsets marcados deliberadamente como ilustrativos, porque esa es la parte que muerde. No copies una tabla de campos de un blog y la pongas en producción. Abre tu propia muestra, confirma los offsets, y solo entonces fíate del bucle.

Las dos cosas que rompen de verdad

La conversión del FILETIME. Son intervalos de 100 nanosegundos desde el 1601-01-01 UTC. Cada línea de tiempo errónea que he revisado falló esto por poco.

from datetime import datetime, timezone

def filetime_to_dt(ft):
    if ft == 0:
        return None
    return datetime.fromtimestamp(ft / 10_000_000 - 11_644_473_600, tz=timezone.utc)

Protege el caso cero. Un FILETIME de 0 significa "sin establecer", no 1601, y pasarlo por el cálculo produce una marca temporal de aspecto real del siglo diecisiete que envenenará en silencio una línea de tiempo.

La divergencia de versión de la DestList. Windows 7 escribía la versión 1. Windows 10 y 11 escriben con frecuencia la versión 3 o 4. El tamaño de las entradas y varios offsets de campos cambiaron entre ellas. Un bucle cableado a una versión lee los archivos posteriores desalineados, y el síntoma es sutil: los nombres de host salen como galimatías, las rutas parecen truncadas, el cursor de bytes deriva un poco más con cada entrada. Si tu salida se degrada progresivamente a lo largo de un archivo, tienes un desajuste de versión, no un archivo corrupto. Ramifica según la versión de la cabecera y mantén tablas de offsets por versión.

Lo que olefile no hará por ti

customDestinations-ms es un formato distinto. No es OLE. Es una secuencia de estructuras LNK con su propio encuadre, así que OleFileIO lanza una excepción con él y lo parseas a mano o con un lector dedicado. El artículo sobre el formato de archivo customDestinations cubre esa estructura. Confundir los dos es la razón número uno de que un "parser de Jump List" funcione con unos archivos y lance una excepción con otros.

Cuando solo necesitas mirar un archivo sin escribir nada de esto, el parser de la página de inicio ejecuta la misma decodificación en el navegador, del lado del cliente, sin subir nada. El script es para cuando lo necesitas en un pipeline.