Safe Penetration Testing Labs · intermediate · ~14 min

Score a domain for DGA likelihood

Combine simple lexical signals into a 0-100 score that flags algorithmically-generated domains.

Overview

Malware that uses a domain-generation algorithm produces names like xkqbvnzrtp.com — pronounceable-looking to a parser but statistically unlike names humans choose. Scoring one means turning lexical features into a number: how long is it, how many digits does it contain, what is the longest run of consonants, and how low is the vowel ratio. The essential engineering point is that the formula must be exact and integer-based, so the same input always yields the same score and the threshold means the same thing every time it is applied.

Why it matters

DGA domains appear in DNS logs long before anything else about an infection is visible, so lexical scoring is a practical early-warning signal that needs no threat feed. It is also a clean lesson in building a reproducible heuristic: floating-point arithmetic and vague thresholds make a detector whose behaviour drifts between machines and cannot be tuned with confidence.

Core concepts

Features, computed in one pass. Length, digit count, vowel count, and the longest consonant run can all be accumulated in a single scan of the label.

Longest consonant run. Human-chosen names alternate vowels and consonants fairly regularly; a run of five or more consonants is unusual and a strong DGA signal.

Vowel ratio. Ordinary English words sit around 35-40% vowels. A ratio far below that suggests random generation.

Digit ratio. Many DGAs mix digits into the label; most legitimate brand domains do not.

Integer arithmetic and an exact formula. Use scaled integers (percentages) rather than floating point, and write the weights explicitly. Two runs on two machines must agree exactly, or a tuned threshold is worthless.

Clamp the output. Constrain the final score to 0..100 so the scale is stable no matter how the weights are adjusted.

Score the right part of the name. Analyse the registrable label, not the whole hostname — the TLD and any subdomains carry no DGA signal and would dilute the features.

A score is a prioritisation, not a verdict. CDN and cloud hostnames are frequently random-looking; the score ranks what a human or a second system should look at.

Syntax notes

#include <stddef.h>

static int is_vowel(char c) {
    return c=='a'||c=='e'||c=='i'||c=='o'||c=='u';
}

/* 0..100; exact integer formula so results are fully reproducible */
int dga_score(const char *s) {
    int len = 0, digits = 0, vowels = 0, run = 0, longest = 0;
    for (const char *p = s; *p; p++) {          /* ONE pass for every feature */
        char c = *p; len++;
        if (c >= '0' && c <= '9') { digits++; run = 0; }
        else if (is_vowel(c))     { vowels++; run = 0; }
        else                      { run++; if (run > longest) longest = run; }
    }
    if (len == 0) return 0;

    int vowel_pct = (vowels * 100) / len;       /* integer percent, no floating point */
    int digit_pct = (digits * 100) / len;

    int score = 0;
    if (len > 12)        score += 15;           /* long labels are less common */
    score += digit_pct;                          /* digits are a strong signal */
    if (longest >= 5)    score += 30;            /* long consonant runs are unusual */
    else if (longest == 4) score += 15;
    if (vowel_pct < 25)  score += 25;            /* far below normal English */

    if (score > 100) score = 100;                /* clamp to a stable scale */
    if (score < 0)   score = 0;
    return score;
}

Key points:

  • Multiply before dividing (vowels * 100 / len) so integer truncation does not destroy the ratio.
  • Every weight is a visible literal — the formula can be reviewed, tuned and reproduced exactly.
  • The len == 0 guard prevents a division by zero.

Lesson

Malware families use Domain Generation Algorithms to produce throwaway C2 domains like kq3xzwrtplf.net. These read as random: long consonant runs, few vowels, stray digits — unlike paypal or microsoft.

The run below scores several domains with an exact integer formula combining the longest consonant run, digit ratio, and vowel ratio. A single tunable number lets a pipeline threshold suspicious lookups for review.

Code examples

#include <stdio.h>
static int dga_score(const char*s){int L=0;for(const char*p=s;*p;p++)L++;if(L==0)return 0;int cr=0,run=0,dg=0,vo=0;for(const char*p=s;*p;p++){char c=*p;char lc=(c>='A'&&c<='Z')?(char)(c+32):c;int a=(lc>='a'&&lc<='z');int v=(lc=='a'||lc=='e'||lc=='i'||lc=='o'||lc=='u');if(c>='0'&&c<='9')dg++;if(a&&v)vo++;if(a&&!v){if(++run>cr)cr=run;}else run=0;}int t1=10*cr;if(t1>40)t1=40;int t2=(dg*50)/L;int t3=((vo*100)/L<20)?20:0;int s2=t1+t2+t3;if(s2>100)s2=100;return s2;}
int main(void){
    const char *domains[] = { "paypal", "microsoft", "kq3xzwrtplf", "x7k2mq9zvb" };
    for(int i=0;i<4;i++) printf("%-14s dga_score=%d\n", domains[i], dga_score(domains[i]));
    return 0;
}

Line by line

Step Line What happens
1 single for loop Length, digits, vowels and the consonant run are all accumulated in one pass.
2 run = 0 on vowel/digit A consonant run is broken by anything that is not a consonant.
3 if (run > longest) Tracks the maximum inside the loop, so a run ending mid-label still counts.
4 (vowels * 100) / len Multiplying first preserves precision; vowels / len * 100 would be 0 for every input.
5 weighted additions Each feature contributes a fixed, visible amount — the formula is auditable.
6 clamp to 0..100 Keeps the scale stable so a threshold retains its meaning as weights change.

Common mistakes

Using floating point (rounding drift breaks reproducibility); forgetting the per-term and overall caps.

Debugging tips

Compiler errors and warnings:

  • -Wdiv-by-zero style warnings if the len == 0 guard is missing (many compilers will not catch the runtime case).
  • -Wchar-subscripts if you pass a char to <ctype.h> helpers; cast to unsigned char.

Runtime symptoms:

  • Every ratio is 0. You divided before multiplying — integer division truncates vowels / len to 0 for any label longer than the vowel count.
  • Crash or undefined result on an empty label. The zero-length guard is missing.
  • Scores above 100. The clamp is missing, so thresholds stop meaning anything.
  • Uppercase domains score differently. Normalise to lowercase first; DNS labels are case-insensitive.
  • Legitimate CDN hostnames score high. Expected — random-looking hostnames are common in cloud infrastructure. Use an allowlist and treat the score as a ranking.
  • Scores differ between machines. You used floating point somewhere. Keep it entirely integer.

Technique: score a set of known-good domains (google, wikipedia, microsoft) and a set of clearly generated ones, then choose the threshold from where the two populations separate — not from a number you guessed.

Memory safety

  • Guard the zero-length label before dividing; a division by zero is undefined behaviour, not merely a wrong score.
  • Multiply before dividing, and be aware that vowels * 100 could overflow for absurdly long inputs — bound the label length (DNS labels are at most 63 characters anyway, which is a natural validation point).
  • Normalise case and validate the character set before scoring; unexpected bytes in a hostname are themselves suspicious and should be handled explicitly rather than silently counted as consonants.
  • unsigned char for any <ctype.h> use, since a negative char passed to those functions is undefined.
  • Read-only input. const char * — the scorer does not modify the name it is given.
  • Analysis only. This reads log-derived strings; it never resolves a domain or contacts a host, which keeps the exercise entirely offline.

Real-world uses

Concrete uses: DNS-security platforms score every queried domain and surface the outliers, which is how DGA-based command-and-control is frequently caught before any payload analysis. The same lexical features feed phishing-domain detection (typosquats and random registrations) and spam filtering. Threat-hunting teams use the score to rank a day's unique domains into a reviewable list.

Professional best practices:

Beginner:

  • Multiply before dividing when computing integer ratios.
  • Clamp the final score so the scale is stable.

Intermediate:

  • Score the registrable label only — subdomains and TLDs dilute the signal.
  • Maintain an allowlist for CDN and cloud domains, which legitimately look random; without one the false-positive rate makes the score unusable.
  • Choose thresholds from measured distributions of your own traffic, and keep the formula in one reviewable place so tuning is deliberate rather than accidental.

Practice tasks

1. (Beginner) Longest consonant run. Implement int longest_consonant_run(const char *label). Example: "xkqbvn" -> 6; "banana" -> 1. Concepts: run tracking.

2. (Beginner) Integer ratios. Compute vowel and digit percentages with multiply-before-divide, and show what happens if you reverse the order. Concepts: integer truncation.

3. (Intermediate) The full score. Implement dga_score exactly as specified, including the clamp and the zero-length guard. Example: "google" scores low; "xkq7bvnzrtp" scores high. Concepts: reproducible heuristics.

4. (Intermediate) Pick a threshold. Score twenty known-good and twenty generated labels, and choose a cut-off from where the distributions separate. Concepts: data-driven thresholds.

Summary

DGA scoring turns lexical features — label length, digit ratio, longest consonant run and vowel ratio — into a single number, all computed in one pass. The engineering discipline matters as much as the features: use integer arithmetic with an exact, visible formula so two runs always agree and a tuned threshold keeps its meaning, multiply before dividing so ratios do not truncate to zero, guard the empty label against division by zero, and clamp the result to a stable 0..100 scale. Score the registrable label rather than the whole hostname, normalise case first, and treat the output as a prioritisation — cloud and CDN hostnames look random too, so an allowlist and a human are part of the system.

Practice with these exercises