Cracking Passwords from Embedded Linux Devices: The musl DES Crypt $ Salt Problem
When extracting /etc/shadow from embedded Linux devices — routers, IoT gateways, cameras — you’ll occasionally encounter password hashes that every standard cracking tool refuses to touch. Not because the hash is strong, but because the C library that generated it plays by slightly different rules.
This post documents a specific case: DES crypt hashes with a $ character in the salt, generated by musl libc on an OpenWrt-based router. The fix is a one-character substitution that makes the hash crackable in seconds.
The Hash#
During security research on an OpenWrt-based embedded device, I extracted /etc/shadow and found:
root:$RyCzhAkI.qmg:19873:0:99999:7:::
The hash is $RyCzhAkI.qmg — 13 characters, which is the telltale length of a traditional DES crypt hash (2-character salt + 11-character hash body).
Every Tool Rejects It#
hashcat (mode 1500 — descrypt)#
$ echo '$RyCzhAkI.qmg' > hash.txt
$ hashcat -m 1500 hash.txt rockyou.txt --force
Hashfile 'hash.txt' on line 1 ($RyCzhAkI.qmg): Token encoding exception
No hashes loaded.
“Token encoding exception” — hashcat validates that every character in the hash belongs to the DES crypt alphabet and rejects any that don’t.
John the Ripper#
$ echo 'root:$RyCzhAkI.qmg' > hash.txt
$ john --wordlist=rockyou.txt hash.txt
Loaded 1 password hash (descrypt, traditional crypt(3) [DES 256/256 AVX2])
0g 0:00:00:01 DONE 0g/s 12704Kp/s 12704Kc/s
Session completed.
John loads the hash (it’s more lenient about salt characters) but exhausts rockyou.txt without a crack. More on why later.
Python/Perl crypt()#
>>> import crypt
>>> crypt.crypt("password", "$R")
'*0'
*0 is the error return — glibc sees $R and tries to parse it as a modular crypt format prefix, fails, and returns the error sentinel.
$ perl -e 'print crypt("password", "\$R"), "\n"'
*0
Same result. The system’s crypt() function won’t even generate hashes with this salt, let alone verify them.
Why $ Is the Problem#
Traditional DES crypt uses a 64-character alphabet for encoding:
./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
Each character maps to a 6-bit value (0–63). The 2-character salt encodes a 12-bit value that perturbs the DES key schedule.
The $ character (ASCII 0x24) is not in this alphabet. It falls between 9 (ASCII 0x39) and A (ASCII 0x41) in the ASCII table — outside the valid range.
On glibc systems, $ has a special meaning: it’s the prefix delimiter for modular crypt format ($1$ for MD5, $5$ for SHA-256, $6$ for SHA-512). When glibc’s crypt() sees a salt starting with $, it tries to parse the $id$salt$hash format, fails, and returns an error.
musl Does It Differently#
The device runs musl libc — standard on OpenWrt-based embedded Linux. You can confirm this by checking for the dynamic linker:
# ls /lib/ld-musl-arm.so.1
/lib/ld-musl-arm.so.1
musl’s DES crypt implementation doesn’t reject $. Instead, it maps the character to a 6-bit value using arithmetic on the ASCII code. The mapping effectively wraps around, and $ ends up producing the same 6-bit value as q.
I verified this on the device itself using OpenSSL (which links against musl):
# openssl passwd -crypt -salt '$R' 'firmware'
$RyCzhAkI.qmg
# openssl passwd -crypt -salt 'qR' 'firmware'
qRyCzhAkI.qmg
The hash bodies are identical: yCzhAkI.qmg. Only the salt prefix differs — because the salt is stored literally in the output, but the actual DES computation used the same 6-bit value for both $ and q.
The Mapping#
musl’s ascii_to_bin() (in crypt_des.c) converts salt characters to 6-bit values. When $ (ASCII 36) hits this function, the arithmetic wraps it to the same bucket as q (ASCII 113).
The practical mapping for the non-standard characters you might encounter:
| musl salt char | ASCII | Equivalent standard char | ASCII |
|---|---|---|---|
$ |
0x24 | q |
0x71 |
! |
0x21 | n |
0x6E |
" |
0x22 | o |
0x6F |
# |
0x23 | p |
0x70 |
% |
0x25 | r |
0x72 |
& |
0x26 | s |
0x73 |
' |
0x27 | t |
0x74 |
You can verify any mapping on the device:
# openssl passwd -crypt -salt '&R' 'test'
&R<hash_body>
# openssl passwd -crypt -salt 'sR' 'test'
sR<same_hash_body>
The Fix: One-Character Substitution#
Replace non-standard salt characters with their standard equivalents:
Original (from device): $RyCzhAkI.qmg
Converted (for hashcat): qRyCzhAkI.qmg
That’s it. The hash body stays the same — the DES computation is identical because both characters map to the same 6-bit value.
Cracking the Converted Hash#
hashcat#
$ echo 'qRyCzhAkI.qmg' > hash.txt
$ hashcat -m 1500 hash.txt rockyou.txt --force
qRyCzhAkI.qmg:firmware
Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 1500 (descrypt, DES (Unix), Traditional DES)
Hash.Target......: qRyCzhAkI.qmg
The converted hash loads cleanly and cracks immediately — the “Token encoding exception” is gone. In this case, the password was firmware — a common default on embedded devices.
Brute Force (DES max = 8 characters)#
DES crypt only uses the first 8 characters of any password. This makes brute force feasible for non-dictionary passwords:
# Incremental brute force, all printable ASCII, up to 6 chars
$ hashcat -m 1500 hash.txt -a 3 '?a?a?a?a?a?a' --force --increment
# Common patterns for 7-8 chars
$ hashcat -m 1500 hash.txt -a 3 '?u?l?l?l?d?d?d?d' --force # Word1234
$ hashcat -m 1500 hash.txt -a 3 '?u?l?l?l?l?l?d?d' --force # Passwd12
At 756 kH/s on CPU:
- 6 chars (95^6): ~16 minutes
- 7 chars (95^7): ~26 hours
- 8 chars (95^8): ~100 days (use GPU)
On-Device Verification#
If you have shell access, use the device’s own OpenSSL to verify candidates. This guarantees the correct crypt implementation:
# On the device (musl libc)
# openssl passwd -crypt -salt '$R' 'candidate'
$R<hash_to_compare>
When You’ll Hit This#
This affects any embedded Linux device using musl libc for password hashing. Common platforms:
- OpenWrt-based routers (various consumer brands)
- Alpine Linux containers and embedded systems
- Buildroot systems configured with musl
- Custom embedded Linux distributions targeting small footprint
Detection: look for /lib/ld-musl-*.so.1 on the device, or check file output on any binary — musl-linked binaries show “dynamically linked, interpreter /lib/ld-musl-arm.so.1” (or similar for the target architecture).
Key Takeaways#
$in a DES crypt salt means musl libc generated the hash. glibc never produces these.- musl maps
$to the same DES value asq. Replace$withqin the salt to make the hash compatible with hashcat and other standard tools. - The hash body doesn’t change. The substitution only affects the literal salt prefix stored in the output — the underlying DES computation is identical.
- DES crypt is always max 8 characters. Regardless of the actual password length, only the first 8 characters matter. Brute force is practical.
- When in doubt, use the device itself. If you have shell access, the device’s
openssl passwd -cryptwill always produce correct results against its own hashes.