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#

  1. $ in a DES crypt salt means musl libc generated the hash. glibc never produces these.
  2. musl maps $ to the same DES value as q. Replace $ with q in the salt to make the hash compatible with hashcat and other standard tools.
  3. The hash body doesn’t change. The substitution only affects the literal salt prefix stored in the output — the underlying DES computation is identical.
  4. DES crypt is always max 8 characters. Regardless of the actual password length, only the first 8 characters matter. Brute force is practical.
  5. When in doubt, use the device itself. If you have shell access, the device’s openssl passwd -crypt will always produce correct results against its own hashes.