Safe Penetration Testing Labs · intermediate · ~12 min

Identify a hash by its format

Map a bare digest to its algorithm family by length and shape — the first step before cracking.

Overview

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.

Why it matters

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.

Core concepts

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.

Syntax notes

#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:

  • Charset validation precedes the length switch, or non-hex strings get classified by accident.
  • The function is named digest_bits, not identify_algorithm — the name states exactly what it can prove.
  • bcrypt needs both the prefix and the 60-character length; either alone is weaker evidence.

Lesson

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.

Code examples

#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;
}

Line by line

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.

Common mistakes

Guessing from length without checking the charset; claiming to tell MD5 from NTLM (both are 128-bit).

Debugging tips

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:

  • A base64 string is classified as a digest. The charset check ran after the length switch, or not at all.
  • Uppercase hashes are rejected. Your hex test only accepts lowercase; both cases are valid.
  • A 60-character bcrypt is reported as unknown by digest_bits. That is correct — bcrypt is not plain hex, and is_bcrypt is the function for it.
  • Confident but wrong algorithm names. You mapped 32 hex characters to "MD5" unconditionally; NTLM hashes are also 32 hex characters and the distinction matters enormously in an assessment.
  • An empty string returns a length-based answer. Guard 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.

Memory safety

  • 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.
  • Guard the empty string before any length-based reasoning.
  • No manual indexing beyond the validated length, and strncmp is bounded, so the prefix checks are safe even on short inputs.
  • Do not log or copy hash values around. Even a hash is sensitive: it is credential-derived material, and spreading it through logs and tickets increases exposure.
  • Read-only input. const char * throughout; classification never modifies its input.
  • Classification only. Nothing here attempts recovery; the lesson is about handling and triage of data you are authorised to examine.

Real-world uses

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:

  • Validate the character set before reasoning about length.
  • Accept both cases of hex.

Intermediate:

  • Report a set of candidates with the digest size, never a single confident algorithm name for a bare hex string.
  • Prefer formats that carry an explicit algorithm identifier (bcrypt, Argon2, crypt tags) when designing storage — self-describing hashes make migration and auditing possible.
  • Recognise base64 and raw encodings as well as hex; the same digest appears in several encodings.

Practice tasks

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.

Summary

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.

Practice with these exercises