Safe Penetration Testing Labs · intermediate · ~14 min
Combine simple lexical signals into a 0-100 score that flags algorithmically-generated domains.
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.
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.
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.
#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:
vowels * 100 / len) so integer truncation does not destroy the ratio.len == 0 guard prevents a division by zero.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.
#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;
}
| 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. |
Using floating point (rounding drift breaks reproducibility); forgetting the per-term and overall caps.
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:
vowels / len to 0 for any label longer than the vowel count.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.
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).unsigned char for any <ctype.h> use, since a negative char passed to those functions is undefined.const char * — the scorer does not modify the name it is given.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:
Intermediate:
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.
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.