All posts
How to · KQL Series · Kusto Detective Agency Part 8/8

KDA: Echoes of Deception - Case 6

Solving KDA Case 6: decoding a book cipher with extract_all, replace_strings, and mv-expand to infiltrate Kuanda.org and find its leader.

Tom · 10 min read
KDA: Echoes of Deception - Case 6

A shadowy cyber organization recruits through the National Gallery of Art. Their encoded instructions are intercepted. Time to crack the cipher.

The problem

This is the abridged case description:

Kuanda.org isn't just some run-of-the-mill organization. It's a brand new cyber organization that's all about digital data repositories. They've been recruiting cyber-crime specialists like there's no tomorrow. And here's the kicker - every new member has to spend a week at the National Gallery of Art! What could they possibly be doing in there for a whole week?

My sources managed to snag some instructions for the new recruiters. If you can decode them, you might just have a shot at infiltrating their system and finding out what they're really up to.

The encoded instructions look like this:

12204/497 62295/24 50883/678 47108/107 193867/3,
45534/141 hidden 100922/183 143461/1 1181/505 46187/380.
41526/155 66447/199 30241/114, 33745/154 12145/387 46437/398 177191/131:
293/64 41629/1506 210038/432, 41612/803 216839/1.

404/258 rules 40/186 1472/222 122894/2 46081/105:
41594/650 32579/439 44625/141 184121/19 33254/348 357/273 32589/821,
46171/687 punctuations 62420/10 50509/48 1447/128,
176565/82'56721/591 561/225 insensitive, 30744/129 76197/32.

1319/42 41599/216 68/457 136016/146, 42420/126'46198/389 42429/158 40091/108 41667/252,
1515/555 177593/223 176924/73 45889/65 159836/96 35080/384 32578/199.
1607/167 124996/9 71/56, 1303/187 45640/1114 72328/247 75802/11,
1168/146 163380/12 57541/116 206122/738 365/267 46026/211 46127/19.

119295/425 45062/128 12198/133 163917/238 45092/8 54183/4 42453/82:
561/433 9/387 37004/287 1493/118 41676/38 163917/238 3159/118 63264/687
1/905 1493/109 43723/252, 136355/1 1159/134 40062/172 32588/604,
158574/1 45411/8 10/892 127587/175 - 633/9 72328/247 1514/615 42940/138.

164958/84 221014/479 151526/7 111124/138, 41668/206 34109/46 1514/555,
147789/2 3228/152 993/323 166477/167 178042/167, 50753/91'207786/8 12/372.
1108/158'42423/150 12/309 66154/9 213566/11 44981/158 1197/300
40184/149 92994/63-71071/179 75093/7 211718/18 74211/5 46144/399.

Five paragraphs of numbers, slashes, and the occasional plaintext word sprinkled in.

And the main question:

Who is the leader of Kuanda.org?

Every new recruit has to spend a week at the National Gallery of Art. We get a table called NationalGalleryArt sourced from their public dataset. The connection isn't subtle - this is a book cipher, and the art collection is the book.

The investigation

Exploring the table

NationalGalleryArt is new - let's see what columns we have:

NationalGalleryArt
| getschema

The ones worth noting: ObjectId (numeric key), and several text fields - Title, Medium, Inscription, ProvenanceText, Creditline. Any of those text fields could be the "book" in our book cipher.

Recognizing the cipher

The number/number pattern is the classic format for a book cipher: the first number identifies a passage, the second identifies a word within it. Our art table has ObjectId as the key - so the first number is likely an ObjectId, and the second is a word position in some text field.

But which text field? The table has several candidates: Title, Medium, Inscription, ProvenanceText, Creditline. The cipher references positions as high as 1506, so the field needs to be wordy. Let's check the string lengths for ObjectId 12204 (the first cipher reference):

NationalGalleryArt
| where ObjectId == 12204
| extend 
    title_words = array_length(split(Title, " ")),
    medium_words = array_length(split(Medium, " ")),
    provenance_words = array_length(split(ProvenanceText, " ")),
    creditline_words = array_length(split(Creditline, " "))
| project ObjectId, title_words, medium_words, provenance_words, creditline_words

Word counts per text field - ProvenanceText has hundreds of words, others single digits

ProvenanceText wins by a landslide - hundreds of words versus single digits for everything else. I had to look up what provenance even means - turns out it traces the ownership history of an artwork, which can span centuries of sales, gifts, and transfers. No wonder the word count is so high.

Decoding the message

Now for the actual decryption. The plan:

  1. Extract all ObjectId/WordPosition pairs from the message
  2. Turn them into individual rows - one per cipher token
  3. Filter the art table down to only the relevant ObjectIds, tokenize their ProvenanceText, and look up the word at each position
  4. Swap every cipher token with its decoded word in one pass using replace_strings

We materialize() both intermediate results because each gets referenced multiple times downstream - CipherPairs filters the art table and then joins back against it, while Decoded builds two separate arrays from the same data. Without it, the engine would re-execute those subqueries on every reference.

The beauty of replace_strings is that it only touches the cipher tokens. Punctuation, line breaks, and plaintext words like "hidden", "rules", "punctuations" - they all survive untouched.

let EncodedMsg = 
```12204/497 62295/24 50883/678 47108/107 193867/3,
45534/141 hidden 100922/183 143461/1 1181/505 46187/380.
41526/155 66447/199 30241/114, 33745/154 12145/387 46437/398 177191/131:
293/64 41629/1506 210038/432, 41612/803 216839/1.

404/258 rules 40/186 1472/222 122894/2 46081/105:
41594/650 32579/439 44625/141 184121/19 33254/348 357/273 32589/821,
46171/687 punctuations 62420/10 50509/48 1447/128,
176565/82'56721/591 561/225 insensitive, 30744/129 76197/32.

1319/42 41599/216 68/457 136016/146, 42420/126'46198/389 42429/158 40091/108 41667/252,
1515/555 177593/223 176924/73 45889/65 159836/96 35080/384 32578/199.
1607/167 124996/9 71/56, 1303/187 45640/1114 72328/247 75802/11,
1168/146 163380/12 57541/116 206122/738 365/267 46026/211 46127/19.

119295/425 45062/128 12198/133 163917/238 45092/8 54183/4 42453/82:
561/433 9/387 37004/287 1493/118 41676/38 163917/238 3159/118 63264/687
1/905 1493/109 43723/252, 136355/1 1159/134 40062/172 32588/604,
158574/1 45411/8 10/892 127587/175 - 633/9 72328/247 1514/615 42940/138.

164958/84 221014/479 151526/7 111124/138, 41668/206 34109/46 1514/555,
147789/2 3228/152 993/323 166477/167 178042/167, 50753/91'207786/8 12/372.
1108/158'42423/150 12/309 66154/9 213566/11 44981/158 1197/300
40184/149 92994/63-71071/179 75093/7 211718/18 74211/5 46144/399.```;
//
// Step 1: Extract all ObjectId/Position pairs
let CipherPairs = materialize(
    print Msg = EncodedMsg
    | extend pairs = extract_all(@'(\d+)/(\d+)', Msg)
    | mv-expand pairs
    | extend ObjId = tolong(pairs[0]), WordPos = tolong(pairs[1])
    | project CipherToken = strcat(ObjId, "/", WordPos), ObjId, WordPos
);
//
// Step 2: Tokenize ProvenanceText, join, look up each word
let Decoded = materialize(
    NationalGalleryArt
    | where ObjectId in ((CipherPairs | distinct ObjId))
    | extend Words = extract_all(@'(\w+)', ProvenanceText)
    | join kind=inner CipherPairs on $left.ObjectId == $right.ObjId
    | extend DecodedWord = tostring(Words[WordPos])
    | project CipherToken, DecodedWord
);
//
// Step 3: Build replacement arrays and swap in one pass
let Lookups = toscalar(Decoded | summarize make_list(CipherToken));
let Replacements = toscalar(Decoded | summarize make_list(DecodedWord));
print replace_strings(EncodedMsg, Lookups, Replacements)

Decoded message showing the full poem with recruit instructions

The decoded message is a poem (bold emphasis mine - I've highlighted the words that turned out to be actual clues):

in catalogue of titles Grand, three hidden words Demand your Hand. when found all, they form A line: A clear timeline, simply Fine.

words rules are simple to Review: at least three Letters have in view, all punctuations Mark the End, they're case insensitive, my friend.

to find all words, you'll need some skill, seeking the popular will guide you still. below The King, the first word mounts, the Second shares with Third their counts.

reveal the last word with Wise thought: take first two letters from word most sought into marked dozen, and change just one, and with those two - the word is done.

so search the titles, high and low, and when you find it, you'll know. you've picked the Image that revealed the pass-code to the World concealed.

I didn't know criminal organizations liked poetry and arts so much.

Decoding the riddle

The poem gives us a set of rules to find three secret words hidden in artwork titles. But fair warning - these clues are deliberately ambiguous, and I went down several wrong paths before landing on the right interpretation.

For example, "below The King" could mean the first word is literally KING - search for artworks with "KING" in the title and pick one. Or it could mean KING itself is the first word, since it's the one singled out by name. The actual answer - the word ranked just below KING in the frequency list - requires reading the poem as describing a sorted ranking, which isn't obvious until you've tried the other interpretations and hit dead ends.

The rules I eventually settled on:

  1. Three words in the Title field form a timeline (something time-related)
  2. Words are case insensitive and must be at least 3 characters
  3. Punctuation marks the end of a word
  4. Sort words by popularity (frequency count)
  5. First word: appears right after "KING" in the sorted list ("below The King")
  6. Second word: shares its count with the word "THIRD" ("the Second shares with Third their counts")
  7. Third word: take the first two letters of the most common word, put them "into" the 12th word ("marked dozen"), and change one letter

Some of the capitalized words in the poem look like they might be data pointers but aren't. "Wise" in "reveal the last word with Wise thought" just means think carefully - it's not a word to look up. The actual pointers are the ones that map to concrete operations: King, Third, dozen.

There are over 53,000 distinct words across the art titles. Rather than scanning the full table for each clue, we materialize the frequency ranking once and run targeted lookups against it.

The extract_all pattern \w{3,} matches words with at least three characters - the {3,} means "three or more word characters." This filters out short words like "of" and "in" that would drown the ranking in noise:

let WordAnalysis = materialize(
    NationalGalleryArt
    | extend TitleWords = extract_all(@'(\w{3,})', toupper(Title))
    | mv-expand TitleWords to typeof(string)
    | summarize WordCount = count() by TitleWords
    | order by WordCount desc
    | extend RowNum = row_number()
);
WordAnalysis
| where RowNum <= 12

Top 12 words by frequency - THE at #1 with 25,417 occurrences, MAN at #12

THE is the most common word at 25,417 occurrences, and MAN sits at rank 12. Both come up in later clues. But KING isn't anywhere near the top - we need to find it in 53,000 rows. Let's look up its neighborhood:

let WordAnalysis = materialize(
    NationalGalleryArt
    | extend TitleWords = extract_all(@'(\w{3,})', toupper(Title))
    | mv-expand TitleWords to typeof(string)
    | summarize WordCount = count() by TitleWords
    | order by WordCount desc
    | extend RowNum = row_number()
);
let KingRank = toscalar(WordAnalysis | where TitleWords == "KING" | project RowNum);
WordAnalysis
| where RowNum between ((KingRank - 2) .. (KingRank + 2))

Rows around KING in the frequency ranking - DAY appears right below KING

Right below KING: DAY. That's our first word.

For the second word, we need to find which word shares its exact count with "THIRD":

let WordAnalysis = materialize(
    NationalGalleryArt
    | extend TitleWords = extract_all(@'(\w{3,})', toupper(Title))
    | mv-expand TitleWords to typeof(string)
    | summarize WordCount = count() by TitleWords
    | order by WordCount desc
    | extend RowNum = row_number()
);
WordAnalysis
| where TitleWords == "THIRD"
| join kind=inner WordAnalysis on WordCount
| where TitleWords1 != "THIRD"
| project TitleWords1, WordCount

Words sharing count 161 with THIRD - DAPHNIS, YEAR, MACCHINA, FRUIT

Several words share the count, but only one makes sense for a timeline: YEAR.

For the third word, the instructions say: take the first two letters of the most common word ("THE" -> "TH"), put them into the 12th word (MAN), and change one letter. This is the most cryptic clue of the bunch - and honestly, it's just a poor clue. "Into" could mean a dozen things: prepend, append, insert at a specific position, interleave. There's no way to arrive at a unique answer logically. The only viable approach is to brute-force all the arrangements and pick whichever produces a recognizable English word:

  • THMAN - not a word
  • MTHAN - not a word
  • MATHN - not a word
  • MANTH - change one letter: MONTH

DAY, YEAR, MONTH - they form a timeline. The riddle checks out.

Finding the image

The last section says: "you've picked the Image that revealed the pass-code to the World concealed." We need to find an artwork whose title contains all three words:

NationalGalleryArt
| where Title has_all ("DAY", "MONTH", "YEAR")
| project Title, ImageUrl

Single artwork with DAY, MONTH, and YEAR in the title

One result. The artwork image shows a grid of letters arranged in squares. When you submitted it to Kuanda.org - the site is now down, and the Wayback Machine doesn't preserve its interactive functionality - an octopus overlay appeared and its tentacles pointed at specific letters in the grid, spelling out STOPKUSTO - the password to infiltrate the organization.

The solution

Logging in with the password reveals the organization's internal page, complete with incriminating evidence and the leader's identity.

The answer: Krypto. That sly dog.

This case is a three-layer puzzle, each layer demanding different KQL techniques:

  1. Book cipher decoding - extract_all(@'(\d+)/(\d+)') parses the cipher tokens, mv-expand flattens them into rows, a join against tokenized ProvenanceText looks up each word, and replace_strings swaps all tokens in one pass. The key insight: replace_strings only touches exact matches, so punctuation and plaintext words survive untouched - no need to manually normalize the message first.

  2. Word frequency analysis - extract_all(@'(\w{3,})', toupper(Title)) tokenizes titles case-insensitively, summarize count() by ranks them, and row_number() assigns positions. The riddle's clues are pointers into this ranking: KING's neighbor, THIRD's count-twin, the 12th word combined with the top word's prefix.

  3. Title search and beyond - has_all finds the one artwork containing DAY, MONTH, and YEAR. But the KQL work ends here - the last steps (following the ImageUrl, reading the octopus tentacles, logging into Kuanda.org) happen outside the query editor.

No time series, no anomaly detection, no massive row counts - just string manipulation, regex, and a riddle that rewards careful reading. A nice change of pace from the previous cases.

Thank you for reading

Tom
Tom, TSQL Dev

SQL Server consultant from Czechia.

Give me a problem where the answer isn't obvious and the evidence doesn't add up. That's my idea of a good time.