From 059d4f8a6d3fabec6b43234a692a66421b6cf2a6 Mon Sep 17 00:00:00 2001 From: Jiang Yio Date: Wed, 30 Jun 2021 08:12:43 -0400 Subject: [PATCH] Initial commit --- .gitignore | 154 +++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 3 ++ mslnk.py | 121 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 278 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 mslnk.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..55be276 --- /dev/null +++ b/.gitignore @@ -0,0 +1,154 @@ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintainted in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..fbc56f3 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# mslnk-py + +Windows shell link (.lnk) parser in Python. \ No newline at end of file diff --git a/mslnk.py b/mslnk.py new file mode 100644 index 0000000..2edd870 --- /dev/null +++ b/mslnk.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 + +from locale import getdefaultlocale +from struct import unpack +from collections import namedtuple +from typing import Any, Tuple + +# https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-shllink/16cb4ca1-9339-4d0c-a68d-bf1d6cc0f943 + +TShellLink = namedtuple('ShellLink', ('ShellLinkHeader', 'LinkTargetIDList', 'LinkInfo', 'StringData', 'ExtraData')) +def parse_shell_link(data: bytes, offset: int=0) -> TShellLink: + shell_link_header = parse_shell_link_header(data, offset) + offset += shell_link_header.HeaderSize + if shell_link_header.LinkFlags & 0x01: + link_target_id_list = parse_link_target_id_list(data, offset) + offset += link_target_id_list.IDListSize + 0x02 + else: + link_target_id_list = None + if shell_link_header.LinkFlags & 0x02: + link_info = parse_link_info(data, offset) + offset += link_info.LinkInfoSize + else: + link_info = None + string_data = parse_string_data(data, offset, shell_link_header.LinkFlags) + offset += string_data.StringDataSize + return TShellLink(shell_link_header, link_target_id_list, link_info, string_data, parse_extra_data(data, offset)) + +TShellLinkHeader = namedtuple('ShellLinkHeader', ('HeaderSize', 'LinkCLSID', 'LinkFlags', 'FileAttributes', 'CreationTime', 'AccessTime', 'WriteTime', 'FileSize', 'IconIndex', 'ShowCommand', 'HotKey')) +def parse_shell_link_header(data: bytes, offset: int=0) -> TShellLinkHeader: + sz = unpack(' TLinkTargetIDList: + sz = unpack(' TLinkInfo: + sz = unpack(' 0 else None + values['LocalBasePath'] = parse_str(data, offset + res.LocalBasePathOffset) if res.LocalBasePathOffset > 0 else None + values['CommonNetworkRelativeLink'] = parse_common_network_relative_link(data, offset + res.CommonNetworkRelativeLinkOffset) if res.CommonNetworkRelativeLinkOffset > 0 else None + values['CommonPathSuffix'] = parse_str(data, offset + res.CommonPathSuffixOffset) if res.CommonPathSuffixOffset > 0 else None + values['LocalBasePathUnicode'] = parse_ustr(data, offset + res.LocalBasePathOffsetUnicode) if res.LocalBasePathOffsetUnicode is not None and res.LocalBasePathOffsetUnicode > 0 else None + values['CommonPathSuffixUnicode'] = parse_ustr(data, offset + res.CommonPathSuffixOffsetUnicode) if res.CommonPathSuffixOffsetUnicode is not None and res.CommonPathSuffixOffsetUnicode > 0 else None + return res._replace(**values) if len(values) > 0 else res + +TCommonNetworkRelativeLink = namedtuple('CommonNetworkRelativeLink', ('CommonNetworkRelativeLinkSize', 'CommonNetworkRelativeLinkFlags', 'NetNameOffset', 'DeviceNameOffset', 'NetworkProviderType', 'NetNameOffsetUnicode', 'DeviceNameOffsetUnicode', 'NetName', 'DeviceName', 'NetNameUnicode', 'DeviceNameUnicode')) +TCommonNetworkRelativeLink.__new__.__defaults__ = (None,)*len(TCommonNetworkRelativeLink._fields) +def parse_common_network_relative_link(data: bytes, offset: int=0) -> TCommonNetworkRelativeLink: + sz = unpack(' 0 else None + values['DeviceName'] = parse_str(data, offset + res.DeviceNameOffset) if res.DeviceNameOffset > 0 else None + values['NetNameUnicode'] = parse_ustr(data, offset + res.NetNameOffsetUnicode) if res.NetNameOffsetUnicode is not None and res.NetNameOffsetUnicode > 0 else None + values['DeviceNameUnicode'] = parse_ustr(data, offset + res.DeviceNameOffsetUnicode) if res.DeviceNameOffsetUnicode is not None and res.DeviceNameOffsetUnicode > 0 else None + return res._replace(**values) if len(values) > 0 else res + +TStringData = namedtuple('StringData', ('StringDataSize', 'NAME_STRING', 'RELATIVE_PATH', 'WORKING_DIR', 'COMMAND_LINE_ARGUMENTS', 'ICON_LOCATION')) +TStringData.__new__.__defaults__ = (None,)*len(TStringData._fields) +def parse_string_data(data: bytes, offset: int=0, LinkFlags: int=0) -> TStringData: + values = {} + cursor = offset + IsUnicode = LinkFlags & 0b10000000 + if LinkFlags & 0b100: + num, values['NAME_STRING'] = parse_pstr(data, cursor, IsUnicode) + cursor += num + 0x02 + if LinkFlags & 0b1000: + num, values['RELATIVE_PATH'] = parse_pstr(data, cursor, IsUnicode) + cursor += num + 0x02 + if LinkFlags & 0b10000: + num, values['WORKING_DIR'] = parse_pstr(data, cursor, IsUnicode) + cursor += num + 0x02 + if LinkFlags & 0b100000: + num, values['COMMAND_LINE_ARGUMENTS'] = parse_pstr(data, cursor, IsUnicode) + cursor += num + 0x02 + if LinkFlags & 0b1000000: + num, values['ICON_LOCATION'] = parse_pstr(data, cursor, IsUnicode) + cursor += num + 0x02 + return TStringData(StringDataSize=cursor - offset, **values) + +def parse_extra_data(data: bytes, offset: int=0) -> Tuple[bytes, ...]: + res = [] + while (sz := unpack('= 0x04: + res.append(data[offset: offset + sz]) + offset += sz + return tuple(res) + +def parse_str(data: bytes, offset: int=0) -> bytes: + return data[offset:].split(b'\x00', 1)[0] if offset > 0 else data.split(b'\x00', 1)[0] + +def parse_ustr(data: bytes, offset: int=0) -> bytes: + return data[offset:].split(b'\x00\x00', 1)[0] if offset > 0 else data.split(b'\x00\x00', 1)[0] + +def parse_pstr(data: bytes, offset: int=0, isunicode: bool=True) -> str: + sz = unpack(' bytes: + sz = unpack(' TShellLink: + if isinstance(x, bytes): + return parse_shell_link(x) + elif hasattr(x, 'read') and callable(x.read): + return parse_shell_link(x.read()) + elif isinstance(x, str): + with open(x, 'rb') as stream: + return parse_shell_link(stream.read())