Last active
May 19, 2026 23:58
-
-
Save ykoster/0a475e4f09e8e5c714ae741933ab21a2 to your computer and use it in GitHub Desktop.
Invoke-MTPuTTYConfigDump - read an MTPuTTY configuration file, decrypt the passwords and dump the result
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .Synopsis | |
| Decrypt an MTPuTTY configuration file | |
| .Description | |
| Read an MTPuTTY configuration file, decrypt the passwords and dump the result | |
| .Parameter ConfigFile | |
| Path to the MTPuTTY configuration file | |
| .Example | |
| Invoke-MTPuTTYConfigDump mtputty.xml | |
| #> | |
| function Invoke-MTPuTTYConfigDump { | |
| [CmdletBinding(DefaultParameterSetName="ConfigFile")] | |
| Param( | |
| [Parameter(ParameterSetName = "ConfigFile", Position = 0, Mandatory = $true)] | |
| [String] | |
| $ConfigPath | |
| ) | |
| $PROV_RSA_FULL = 1 | |
| $CRYPT_VERIFYCONTEXT = 0xF0000000 | |
| $CALG_SHA = 0x00008004 | |
| $CALG_RC2 = 0x00006602 | |
| Function Get-CryptoAPI { | |
| $MethodDefinition = @" | |
| [DllImport("advapi32.dll", CharSet=CharSet.Auto, SetLastError=true)] | |
| [return : MarshalAs(UnmanagedType.Bool)] | |
| public static extern bool CryptAcquireContext(ref IntPtr hProv, string pszContainer, string pszProvider, uint dwProvType, long dwFlags); | |
| [DllImport("advapi32.dll", CharSet=CharSet.Auto, SetLastError=true)] | |
| [return : MarshalAs(UnmanagedType.Bool)] | |
| public static extern bool CryptReleaseContext(IntPtr hProv, uint dwFlags); | |
| [DllImport("advapi32.dll", CharSet=CharSet.Auto, SetLastError=true)] | |
| [return : MarshalAs(UnmanagedType.Bool)] | |
| public static extern bool CryptCreateHash(IntPtr hProv, uint algId, IntPtr hKey, uint dwFlags, ref IntPtr phHash); | |
| [DllImport("advapi32.dll", CharSet=CharSet.Auto, SetLastError=true)] | |
| [return : MarshalAs(UnmanagedType.Bool)] | |
| public static extern bool CryptDestroyHash(IntPtr hHash); | |
| [DllImport("advapi32.dll", CharSet=CharSet.Auto, SetLastError=true)] | |
| [return : MarshalAs(UnmanagedType.Bool)] | |
| public static extern bool CryptHashData(IntPtr hHash, byte[] pbData, uint dataLen, uint flags); | |
| [DllImport("advapi32.dll", CharSet=CharSet.Auto, SetLastError=true)] | |
| [return : MarshalAs(UnmanagedType.Bool)] | |
| public static extern bool CryptDeriveKey(IntPtr hProv,int Algid, IntPtr hBaseData, int flags, ref IntPtr phKey); | |
| [DllImport("advapi32.dll", CharSet=CharSet.Auto, SetLastError=true)] | |
| [return : MarshalAs(UnmanagedType.Bool)] | |
| public static extern bool CryptDestroyKey(IntPtr hKey); | |
| [DllImport("advapi32.dll", CharSet=CharSet.Auto, SetLastError=true)] | |
| [return : MarshalAs(UnmanagedType.Bool)] | |
| public static extern bool CryptDecrypt(IntPtr hKey, IntPtr hHash, int Final, uint dwFlags, byte[] pbData, ref uint pdwDataLen); | |
| "@ | |
| try { | |
| $CryptoAPI = Add-Type -MemberDefinition $MethodDefinition -name advapi32 -Namespace CryptoAPI -PassThru | |
| } catch {} | |
| return [CryptoAPI.advapi32] | |
| } | |
| <# https://devblogs.microsoft.com/powershell/format-xml/ #> | |
| Function Format-XML ([System.Xml.XmlElement]$xml, $indent=2) { | |
| $StringWriter = New-Object System.IO.StringWriter | |
| $XmlWriter = New-Object System.XMl.XmlTextWriter $StringWriter | |
| $xmlWriter.Formatting = "indented" | |
| $xmlWriter.Indentation = $Indent | |
| $xml.WriteContentTo($XmlWriter) | |
| $XmlWriter.Flush() | |
| $StringWriter.Flush() | |
| Write-Output $StringWriter.ToString() | |
| } | |
| try { | |
| [xml]$config = Get-Content -Path $ConfigPath -ErrorAction Stop | |
| } catch { | |
| Write-Host $_ -ErrorAction Stop | |
| return | |
| } | |
| $CryptoAPI = Get-CryptoAPI | |
| [System.IntPtr]$hProv = 0 | |
| $servers = $config.SelectNodes("//Servers") | |
| if($servers.Count -gt 0 -and $CryptoAPI::CryptAcquireContext([ref]$hProv, $null, $null, $PROV_RSA_FULL, $CRYPT_VERIFYCONTEXT) -ne $false) { | |
| foreach($node in $config.SelectNodes("//Node")) { | |
| if($node.Type -eq 0) { | |
| Write-Host "$($node.DisplayName):" | |
| } elseif ($node.Type -eq 1) { | |
| [System.IntPtr]$hHash = 0 | |
| [System.IntPtr]$hKey = 0 | |
| $password = [system.Text.Encoding]::UTF8.GetBytes("1$($node.UserName.Trim())$($node.ServerName.Trim())") | |
| $ciphertext = [System.Convert]::FromBase64String($node.Password.Trim()) | |
| $ciphertextLength = $ciphertext.Length | |
| if($CryptoAPI::CryptCreateHash($hProv, $CALG_SHA, 0, 0, [ref]$hHash) -ne $false) { | |
| if($CryptoAPI::CryptHashData($hHash, $password, $password.Length, 0) -ne $false) { | |
| if($CryptoAPI::CryptDeriveKey($hProv, $CALG_RC2, $hHash, 0, [ref]$hKey) -ne $false) { | |
| if($CryptoAPI::CryptDecrypt($hKey, 0, $true, 0, $ciphertext, [ref]$ciphertextLength) -ne $false) { | |
| $ciphertext = $ciphertext[0..($ciphertextLength-1)] | |
| if($ciphertextLength -ge 2 -and $ciphertext[1] -eq 0) { | |
| $node.Password = [system.Text.Encoding]::Unicode.GetString($ciphertext) | |
| } else { | |
| $node.Password = [system.Text.Encoding]::UTF8.GetString($ciphertext) | |
| } | |
| } | |
| $null = $CryptoAPI::CryptDestroyKey($hKey); | |
| } | |
| } | |
| $null = $CryptoAPI::CryptDestroyHash($hHash); | |
| } | |
| Format-XML $node | |
| Write-Host | |
| } | |
| Write-Host | |
| } | |
| $null = $CryptoAPI::CryptReleaseContext($hProv, 0) | |
| } | |
| } | |
| Export-ModuleMember -Function Invoke-MTPuTTYConfigDump |
Author
How are the initialization vectors generated? This is using RC2_CBC correct?
Hi @sidrile3310, since there is no call to CryptSetKeyParam I assume the IV is all zeroes, which is the default for the Microsoft Base Cryptographic Provider.
@ykoster Thanks! I am trying to write a version of this using Python but so far not so good. Thought the IV may be the issue but it looks like the problem lies elsewhere. This was a great primer though.
Author
@sidrile3310 could be related to the way the key is derived. There is a Python implementation of CryptDeriveKey here https://www.fireeye.com/content/dam/fireeye-www/global/en/blog/threat-research/flareon2016/challenge2-solution.pdf. Haven't tested it myself.
#!/usr/bin/env python3
# Encrypt and decrypt MTPuTTY XML password values.
# Created by VeNoMouS <venom@gen-x.co.nz> on 2026-05-20
import argparse
import base64
import binascii
import hashlib
try:
from Crypto.Cipher import ARC2
from Crypto.Util.Padding import pad, unpad
except ImportError as exc:
raise SystemExit(
"Missing dependency: pycryptodome\n"
"Install it with: python3 -m pip install pycryptodome"
) from exc
# ------------------------------------------------------------------------------- #
BLOCK_SIZE = 8
IV = b"\x00" * BLOCK_SIZE
# ------------------------------------------------------------------------------- #
def keyFor(username, servername):
"""Return the RC2 key derived from MTPuTTY's XML node fields."""
seed = f"1{username.strip()}{servername.strip()}".encode("utf-8")
return hashlib.sha1(seed).digest()[:16]
# ------------------------------------------------------------------------------- #
def decryptPassword(username, servername, b64_password):
try:
plain = unpad(
ARC2.new(
keyFor(username, servername),
ARC2.MODE_CBC,
iv=IV,
effective_keylen=128,
).decrypt(base64.b64decode(b64_password.strip(), validate=True)),
BLOCK_SIZE,
)
except binascii.Error as exc:
raise SystemExit("Could not decrypt: password is not valid Base64") from exc
except ValueError as exc:
raise SystemExit(
"Could not decrypt: check the UserName and ServerName from the same XML node"
) from exc
if len(plain) >= 2 and plain[1] == 0:
return plain.decode("utf-16le")
return plain.decode("utf-8")
# ------------------------------------------------------------------------------- #
def encryptPassword(username, servername, password):
return base64.b64encode(
ARC2.new(
keyFor(username, servername),
ARC2.MODE_CBC,
iv=IV,
effective_keylen=128,
).encrypt(pad(password.encode("utf-16le"), BLOCK_SIZE))
).decode("ascii")
# ------------------------------------------------------------------------------- #
def build_parser():
parser = argparse.ArgumentParser(
description="Encrypt or decrypt MTPuTTY XML Password values.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"examples:\n"
" mtputty_password.py decrypt root server01 U2FtcGxlUGFzc3dvcmQ=\n"
" mtputty_password.py decrypt '' 192.168.0.15 k6LrFZbnRFk=\n"
" mtputty_password.py encrypt root server01 password123\n\n"
"note:\n"
" Empty decrypted passwords are printed as [EMPTY]."
),
)
subparsers = parser.add_subparsers(dest="command", required=True)
decrypt_parser = subparsers.add_parser("decrypt", help="decrypt a Base64 XML password")
decrypt_parser.add_argument("username", help="Node UserName value")
decrypt_parser.add_argument("servername", help="Node ServerName value")
decrypt_parser.add_argument("password", help="Node Password Base64 value")
encrypt_parser = subparsers.add_parser("encrypt", help="encrypt a plaintext password")
encrypt_parser.add_argument("username", help="Node UserName value")
encrypt_parser.add_argument("servername", help="Node ServerName value")
encrypt_parser.add_argument("password", help="plaintext password")
return parser
# ------------------------------------------------------------------------------- #
args = build_parser().parse_args()
if args.command == "decrypt":
print(decryptPassword(args.username, args.servername, args.password) or "[EMPTY]")
elif args.command == "encrypt":
print(encryptPassword(args.username, args.servername, args.password))<Putty>
<Node Type="1">
<SavedSession>desert</SavedSession>
<DisplayName>192.168.0.15</DisplayName>
<ServerName>192.168.0.15</ServerName>
<PuttyConType>4</PuttyConType>
<Port>22</Port>
<UserName></UserName>
<Password>k6LrFZbnRFk=</Password>
<PasswordDelay>0</PasswordDelay>
<CLParams>-load desert 192.168.0.15 -ssh -P 22</CLParams>
<ScriptDelay>0</ScriptDelay>
</Node>
</Putty>./mtputty_password.py decrypt "" 192.168.0.15 k6LrFZbnRFk=
[EMPTY]
./mtputty_password.py encrypt "" 192.168.0.15 ""
k6LrFZbnRFk=
<Putty>
<Node Type="1">
<SavedSession>Default Settings</SavedSession>
<DisplayName>TEST</DisplayName>
<UID>{55DF5F51-222D-409A-A8F4-0A0B7D00A5A2}</UID>
<ServerName>192.168.2.1</ServerName>
<PuttyConType>4</PuttyConType>
<Port>0</Port>
<UserName>onadm</UserName>
<Password>R7eUjM9rncA=</Password>
<PasswordDelay>0</PasswordDelay>
<CLParams>192.168.2.1 -ssh -l onadm</CLParams>
<ScriptDelay>0</ScriptDelay>
</Node>
</Putty>./mtputty_password.py decrypt onadm 192.168.2.1 R7eUjM9rncA=
123
Key String Build
This is the save path around the password encryption call:
004f3bb1 push 0x4f3dcc ; literal string "1"
004f3bb6 lea edx,[ebp-0x40]
004f3bb9 mov eax,esi
004f3bbb call 0x4f3664 ; get one node field
004f3bc0 mov eax,[ebp-0x40]
004f3bc3 lea edx,[ebp-0x3c]
004f3bc6 call 0x4097cc ; trim/copy field
004f3bcb push [ebp-0x3c]
004f3bce lea edx,[ebp-0x44]
004f3bd1 mov eax,[esi+0x18] ; another stored field
004f3bd4 call 0x4097cc ; trim/copy field
004f3bd9 push [ebp-0x44]
004f3bdc lea eax,[ebp-0x38]
004f3bdf mov edx,0x3
004f3be4 call 0x4050b4 ; concat 3 strings: "1" + field + field
004f3be9 mov eax,[ebp-0x38]
004f3bec push eax ; key string
004f3bed lea edx,[ebp-0x48]
004f3bf0 mov eax,esi
004f3bf2 call 0x4f3694 ; get Password field
004f3bf7 mov eax,[ebp-0x48]
004f3bfa lea ecx,[ebp-0x34]
004f3bfd pop edx ; key string into edx
004f3bfe call 0x4f0940 ; encrypt password with key string
The local string table around 0x4f3db8 is:
0x4f3db8 UserName............1...........Password........
0x4f3dcc 1...........Password............PasswordDelay...
0x4f3dd8 Password............PasswordDelay...........CLPa
Crypto Constructor
This is TStrCryptor setup. It hashes the provided key string and derives an RC2 key:
004f068e push 0xf0000000 ; CRYPT_VERIFY_CONTEXT
004f0693 push 0x1 ; PROV_RSA_FULL
004f0695 push 0x0
004f0697 push 0x0
004f0699 lea eax,[esi+0x4]
004f069c push eax
004f069d call 0x4f01a4 ; CryptAcquireContextA
004f06aa push 0x8004 ; CALG_SHA1
004f06af mov eax,[esi+0x4]
004f06b2 push eax
004f06b3 call 0x4f01cc ; CryptCreateHash
004f06b8 push 0x0
004f06ba mov eax,[ebp-0x4] ; key string
004f06bd call 0x404ff4 ; string length
004f06c2 push eax
004f06c3 lea eax,[ebp-0x4]
004f06c6 call 0x40524c ; string bytes
004f06cb push eax
004f06cc mov eax,[ebp-0x8]
004f06cf push eax
004f06d0 call 0x4f01d4 ; CryptHashData
004f06df push 0x6602 ; CALG_RC2
004f06e4 mov eax,[esi+0x4]
004f06e7 push eax
004f06e8 call 0x4f01b4 ; CryptDeriveKey
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
How are the initialization vectors generated? This is using RC2_CBC correct?