Safe Penetration Testing Labs · intermediate · ~12 min
Map a bare digest to its algorithm family by length and shape — the first step before cracking.
Given an unlabelled hash from a database dump or a log, the first question is what produced it. You cannot answer definitively — a hash is just bytes — but you can narrow it a great deal from three surface features: the character set, the length, and any prefix. All-hex and 32 characters means a 128-bit digest; 40 means 160-bit; 64 means 256-bit. A $2b$ prefix with a 60-character body is bcrypt. The discipline this lesson teaches is being precise about what those observations do and do not prove.
Identifying the family tells you how the data should be handled: a fast unsalted digest of passwords is an emergency, while bcrypt buys real time. It also drives tool selection during authorised assessments and incident response. Most importantly, it teaches honest classification — 32 hex characters is MD5 or NTLM or a truncated something-else, and a tool that claims certainty it does not have will mislead the person relying on it.
Validate the character set first. If any character is outside [0-9a-fA-F], it is not a plain hex digest and the length tells you nothing. Doing this check first avoids nonsense conclusions on base64 or arbitrary text.
Length maps to digest size. 32 hex characters = 128 bits, 40 = 160, 56 = 224, 64 = 256, 96 = 384, 128 = 512. Two hex characters per byte is the whole conversion.
Length identifies size, not algorithm. 32 hex characters is MD5, NTLM, MD4 or a truncation of something larger. 64 is SHA-256, SHA3-256, BLAKE2s and others. A correct tool reports "128-bit digest, candidates: …" rather than asserting "MD5".
Prefixed formats are the reliable ones. bcrypt ($2a$/$2b$/$2y$, 60 characters), Argon2 ($argon2id$…) and the crypt family carry explicit algorithm identifiers, so those can be identified with confidence.
Encoding matters too. The same digest appears as hex, base64 or raw bytes; recognising base64's alphabet and = padding prevents misreading a 44-character base64 SHA-256 as an unknown format.
Case is not a signal. Uppercase and lowercase hex are equally valid; accept both and never treat casing as evidence.
#include <string.h>
/* all characters hex? case-insensitive */
static int all_hex(const char *h, size_t n) {
if (n == 0) return 0;
for (size_t i = 0; i < n; i++) {
char c = h[i];
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')))
return 0;
}
return 1;
}
/* digest SIZE in bits, or -1 if not a recognised plain hex digest */
int digest_bits(const char *h) {
size_t n = strlen(h);
if (!all_hex(h, n)) return -1; /* charset FIRST - length is meaningless otherwise */
switch (n) {
case 32: return 128; /* MD5 / NTLM / MD4 - size, not algorithm */
case 40: return 160; /* SHA-1 / RIPEMD-160 */
case 64: return 256; /* SHA-256 / SHA3-256 / BLAKE2s */
case 128: return 512; /* SHA-512 / SHA3-512 */
default: return -1;
}
}
int is_bcrypt(const char *h) {
return strlen(h) == 60 &&
(strncmp(h, "$2a$", 4) == 0 || strncmp(h, "$2b$", 4) == 0
|| strncmp(h, "$2y$", 4) == 0);
}
Key points:
digest_bits, not identify_algorithm — the name states exactly what it can prove.Before you can crack a captured hash you have to identify it. A raw hex digest's length narrows the family: 32 hex chars = 128-bit (MD5-family), 40 = SHA-1, 64 = SHA-256. bcrypt is unmistakable: a $2a/$2b/$2y$ prefix and exactly 60 characters.
The run below classifies several digests. Note the honest limit: a 32-hex string could be MD5 or NTLM — length alone can't separate them.
#include <stdio.h>
#include <string.h>
static int hx(const char*h,size_t n){for(size_t i=0;i<n;i++){char c=h[i];if(!((c>='0'&&c<='9')||(c>='a'&&c<='f')||(c>='A'&&c<='F')))return 0;}return 1;}
static int digest_bits(const char*h){size_t n=strlen(h);if(!hx(h,n))return -1;if(n==32)return 128;if(n==40)return 160;if(n==64)return 256;return -1;}
static int is_bcrypt(const char*h){if(strlen(h)!=60)return 0;return(strncmp(h,"$2a$",4)==0||strncmp(h,"$2b$",4)==0||strncmp(h,"$2y$",4)==0)?1:0;}
int main(void){
const char *samples[] = {
"5f4dcc3b5aa765d61d8327deb882cf99",
"aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d",
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" };
for(int i=0;i<3;i++) printf("%d-bit digest\n", digest_bits(samples[i]));
char bc[61]; memset(bc,'x',60); bc[60]='\0'; memcpy(bc,"$2b$12$",7);
printf("bcrypt? %s\n", is_bcrypt(bc)?"yes":"no");
return 0;
}
| Step | Line | What happens |
|---|---|---|
| 1 | strlen(h) |
Shadow-style and hex hashes are NUL-terminated text, so strlen is appropriate here. |
| 2 | all_hex(h, n) |
Rejects base64, salted formats and arbitrary text before any length reasoning. |
| 3 | case 32: return 128 |
32 hex characters is 16 bytes — a 128-bit digest. Two hex characters per byte. |
| 4 | comment on the case | Deliberately records that this is MD5 or NTLM or MD4 — the size is known, the algorithm is not. |
| 5 | default: return -1 |
An unrecognised length is reported as unknown rather than guessed. |
| 6 | is_bcrypt |
Prefix and length together; bcrypt is one of the formats that can be identified with confidence. |
Guessing from length without checking the charset; claiming to tell MD5 from NTLM (both are 128-bit).
Compiler errors and warnings:
-Wsign-compare between size_t n and int case labels; keep n a size_t and switch on it directly.-Wimplicit-fallthrough if you omit return/break in the switch.Runtime symptoms:
digest_bits. That is correct — bcrypt is not plain hex, and is_bcrypt is the function for it.n == 0 explicitly.Technique: test one sample per length, one uppercase, one base64 string, one bcrypt and one empty string. That set exercises the charset gate, the length map and both honesty cases.
strlen is safe here because these are NUL-terminated text fields, unlike the binary blobs elsewhere in this track — knowing which situation you are in is the point.strncmp is bounded, so the prefix checks are safe even on short inputs.const char * throughout; classification never modifies its input.Concrete uses: Incident responders classify hashes found in a database dump to judge exposure — unsalted fast digests mean immediate forced resets, bcrypt means more time. Migration tools detect legacy formats to rehash on next login. Authorised assessment tooling uses format identification to select the right approach. Log-analysis pipelines use it to spot credential material that should never have been logged.
Professional best practices:
Beginner:
Intermediate:
1. (Beginner) Hex validation. Implement all_hex accepting both cases and rejecting the empty string. Concepts: charset gating.
2. (Beginner) Digest size. Implement int digest_bits(const char *h) returning 128/160/256/512 or -1. Example: a 32-char hex string -> 128; a 40-char -> 160; "hello" -> -1. Concepts: length-to-size mapping.
3. (Intermediate) bcrypt detection. Implement int is_bcrypt(const char *h) requiring both prefix and length. Example: a 60-char $2b$ string -> 1. Concepts: self-describing formats.
4. (Intermediate) Honest reporting. Print a candidate list for each recognised size ("128-bit: MD5, NTLM, MD4") rather than a single name. Concepts: reporting uncertainty accurately.
You can narrow an unlabelled hash from three surface features — character set, length and prefix — but you cannot always name the algorithm, and the discipline is saying so. Validate that the string is hex first, because length reasoning on a base64 or arbitrary string is meaningless, then map two hex characters per byte: 32 chars is a 128-bit digest, 40 is 160-bit, 64 is 256-bit. That size is a fact; the algorithm is a candidate list, since 32 hex characters could be MD5, NTLM or MD4. Formats that carry an explicit identifier — bcrypt's $2b$ with 60 characters, Argon2, the crypt tags — are the ones you can identify with confidence, which is a good argument for choosing self-describing formats when you design storage.