m0leCon 2026 — In (Secure) Vault 2 Writeup
A .NET 8 WPF vault app guards its gate with a client-side CheckPassword: the ptm flag is split into leet fragments verified with MD5, SHA-256, base64 and base32, then reassembled by decompiling the binary.
m0leCon 2026 — In (Secure) Vault 2
"In (Secure) Vault 2" (Beginner, rev) ships a single archive: InsecureVault.tar. Unpacked, it is a self-contained .NET 8 WPF desktop application — InsecureVault.exe + InsecureVault.dll and a handful of runtime assemblies. It presents a login window whose password is the flag: get CheckPassword to return true and the app opens its "Database Manager".
The catch the author names in the description ("a super secure vault for my companies") is that the check lives entirely in the client, and it is not even hidden well — it is just a chain of small hashing puzzles. Decompiling the managed binary hands you every constraint.
Scope: static reverse-engineering of the shipped binaries. The app targets Windows WPF, so I did not run it; the flag is recoverable from the IL alone.
- Event: m0leCon CTF 2026
- Category: rev (Beginner)
- Flag format:
ptm{...}
1. Recon
file/tar on the archive shows an apphost plus a managed assembly. A quick strings pass names the moving parts without any decompiler:
strings -n 5 InsecureVault.dll | grep -iE "sha|md5|password|flag|Login"
# SHA256
# HashAlgorithm
# CheckPassword
# loginPasswordTBox
# LoginButton_Click
# " Inserisci la flag per continuareTwo things stand out. First, a CheckPassword method referenced from LoginButton_Click — that is the gate. Second, no plaintext flag anywhere in strings; the comparison must be against a digest, not a literal. The UI text is Italian ("insert the flag to continue"), consistent with the Italian conference.
An ILSpy-style class listing keeps the target small:
Class InsecureVault.App
Class InsecureVault.DbManager
Class InsecureVault.Company / Employee / Project / Document
Class InsecureVault.MainWindowMainWindow holds the login; DbManager is the post-login screen.
2. Decompiling
The binaries are managed (.NET), so ilspycmd recovers near-original C#. I ran it in a .NET SDK container (the host had no dotnet toolchain). Two environment wrinkles worth recording:
ilspycmd8.2 is anet6.0tool; under anet8.0SDK runtime it needs roll-forward (DOTNET_ROLL_FORWARD=Major).- The mounted directory was read-only, so
-o <dir>failed withRead-only file system; decompiling a single type to stdout sidesteps that.
dotnet tool install -g ilspycmd --version 8.2.0.7535
DLL=$(find /root/.dotnet/tools/.store -name ilspycmd.dll | head -1)
DOTNET_ROLL_FORWARD=Major dotnet "$DLL" -t InsecureVault.MainWindow InsecureVault/InsecureVault.dll3. CheckPassword
The whole challenge is this method. It pins the flag to exactly 45 characters, a ptm{ prefix, and then validates 4- and 5-character slices with a mix of hashes, encodings and integer tricks:
private static bool CheckPassword(string s)
{
if (s.Length != 45) return false;
if (s.Substring(0, 4) != "ptm{") return false;
using (MD5 md = MD5.Create())
if (BitConverter.ToString(md.ComputeHash(Encoding.UTF8.GetBytes(s.Substring(4, 4))))
.Replace("-", "").ToLower() != "33635414851f62863a5cb7825481433d") return false;
if (BitConverter.ToString(Encoding.UTF8.GetBytes(s.Substring(8, 4)))
.Replace("-", "").ToLower() != "5f345f46") return false;
if (Convert.ToBase64String(Encoding.UTF8.GetBytes(s.Substring(12, 4))) != "dW5ueQ==") return false;
string text = "0x" + ((int)s[19]).ToString("x");
if (s[16] != '_' || (s[17] ^ 5) != 114 || s[18] != '4' || text != "0x79") return false;
using (SHA256 sha = SHA256.Create())
if (BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(s.Substring(20, 4))))
.Replace("-", "").ToLower() != "c1cd3414aea97cfb005cf3bf3f39f3c1b5412f5dd8a2602c74a38181d94f5a2d") return false;
if (ToBase32String(Encoding.UTF8.GetBytes(s.Substring(24, 5))) != "MNUDGY3L") return false;
string text2 = "0b" + Convert.ToString(s[30], 2);
if (s[29] != '_' || text2 != "0b1100110" || s[31] != '0' || s[32] != 'R') return false;
if (s.Substring(33, 3) != "_4_" || (int)s[36] >> 4 != 7) return false;
int num = int.Parse(s[39].ToString());
if ((s[37] ^ s[38]) != 1 || s[37] != '4' || num + 10 != 15 || s[40] != 'w') return false;
if (BitConverter.ToString(Encoding.UTF8.GetBytes(s.Substring(41)))
.Replace("-", "").ToLower() != "3072647d") return false;
return true;
}Every slice is independently invertible. The interesting call is ToBase32String, a hand-rolled RFC 4648 encoder over the A–Z2–7 alphabet (padding to a multiple of 8 with =), so MNUDGY3L decodes as ordinary base32.
4. Reassembly
Working the slices in index order:
| Slice | Check | Value |
|---|---|---|
[0:4] | literal | ptm{ |
[4:8] | MD5 = 33635414851f... | Wh4t |
[8:12] | bytes = 5f 34 5f 46 | _4_F |
[12:16] | base64 = dW5ueQ== | unny |
[16:20] | _, s[17]^5==114, 4, hex(s[19])=="0x79" | _w4y |
[20:24] | SHA-256 = c1cd3414... | _t0_ |
[24:29] | base32 = MNUDGY3L | ch3ck |
[29:33] | _, bin(s[30])=="1100110", 0, R | _f0R |
[33:37] | _4_, s[36]>>4==7 | _4_p |
[37:41] | s[37]^s[38]==1, 4, int(s[39])+10==15, w | 455w |
[41:45] | bytes = 30 72 64 7d | 0rd} |
Two slices are 4-character hash preimages, so they are brute-forced. The obvious guesses (such, just, r34l, n0t_) all miss; a full printable-ASCII search over the MD5 and SHA-256 targets finds them immediately:
def brute(target, hashfn, charset):
for combo in itertools.product(charset, repeat=4):
s = "".join(combo)
if hashfn(s.encode()).hexdigest() == target:
return s
charset = [chr(i) for i in range(32, 127)] # printable ASCII
# MD5 33635414851f62863a5cb7825481433d -> 'Wh4t'
# SHA256 c1cd3414aea97cfb005cf3bf3f39f3c1b5412f5dd8a2602c74a38181d94f5a2d -> '_t0_'MNUDGY3L decodes to ch3ck. Everything else is plain arithmetic and encoding inverses.
5. Verification
I re-implemented CheckPassword in Python (including the hand-rolled base32) and ran the candidate through every branch:
PASS length==45
PASS prefix
PASS md5 s[4:8]
PASS hex s[8:12]
PASS b64 s[12:16]
PASS s[16:20]
PASS sha256 s[20:24]
PASS base32 s[24:29]
PASS s[29:33]
PASS s[33:37]
PASS s[37:41]
PASS s[41:]
FLAG: ptm{Wh4t_4_Funny_w4y_t0_ch3ck_f0R_4_p455w0rd} len 45
ALL PASSReading the leet out loud gives the joke: "What a funny way to check for a password." The password is the flag, and the "vault" gates nothing on the server — the checks all run on the client.
For completeness, the post-login DbManager connects to insecurevault.challs.m0lecon.it:5432 with credentials hardcoded in the same assembly (giovanni / a literal MD5-looking string) and loads Companies/Employees/Projects/Documents. Those credentials are also in the client binary — the app has no trust boundary at all.
6. Takeaways
- A client-side password check is decorative. Everything the client needs to accept a password — the digests, the encodings, the ordering — ships to the attacker. A managed .NET assembly decompiles back to readable C#.
- Splitting one secret across MD5/SHA-256/base64/base32/bit tricks adds no security; each fragment is independently invertible, and short preimages fall to a brute force in seconds.
- Hardcoded database credentials in the same assembly are the real bug class behind the challenge's name: the vault trusts anyone who has the binary.
7. Flag
ptm{Wh4t_4_Funny_w4y_t0_ch3ck_f0R_4_p455w0rd}What do you think?
React to show your appreciation