The Case

Kuanda’s inside our network! Can we find a way to use their trojan to finally shut them down?

The Code

Let’s check out the logs

| take 100 

We’ve not been given much to go on, so let’s see if we can find any clues in the ‘‘Message’’ (summarizing by the first few characters to discard any variables in the text).

| summarize count() by substring(Message, 0, 12) 

So there are a few different events to process. And hmm, what are those Sending messages? Expanding, one we can see the detective’s own encryption tokens are being used - very over confident of Kuanda, non?

| project DetectiveId, Timestamp, Message
| sort by DetectivId, Timestamp

Looks like we need to find the active tokens for each of those sent messages to decode Kuandas plot.

If we look through the logs, we can see events for session start and reset, and for operation start and end. Token life is tied to these transitions, so we’re going to need some state machine processing.

This next part isn’t strictly necessary, but filtering and enriching the raw logs into a new table will make things a bit easier as we start to dig further.

First, we’ll filter the raw logs to those detectives who’ve been compromised. Next, we’ll add a label for the Event type, and finally parse out the tokens and associated operation id.

.set-or-replace LogsEx <|
| where Message startswith "Sending"
| distinct DetectiveId
| lookup kind=inner KuandaLogs on DetectiveId
| extend Event = iff(Message has_all("Operation", "started"), "OpStart",
       iff(Message has_all("Operation", "completed"), "OpCompleted",
                 iff(Message startswith "User entered", "SessionStart",
                 iff(Message startswith "User session", "SessionReset",
                 iff(Message startswith "Sending", "MessageSend",
| parse Message with "Operation id=" OperationId:string " " * 
| parse Message with * "token: '" Token:string "'" *;

A bit messy but gets the job done.

| take 100

Now we need to work out which tokens are still active when the encrypted message is semt. Sounds like a job for scan.

Tokens are recorded on operation start, but can be removed by reset or operation end events. We only have the operation id to match start/end events, we’re going to have to store the operation id and token somewhere, removing as reset/end events are received, then finally concatenating the remaining tokens when we detect a send event.

Usually, you’d use something like a key-value list for this - in Kusto a dynamic would be ideal. And this is where I spent soooo much time trying to get a data structure to work.

Turns out bag, array and json functions aren’t fully supported in scan steps, so after many hours I dropped back to using a string to store state. This is pretty icky but hey, there’s criminals to catch and there’s no prize for elegant code.

| partition hint.strategy=native by DetectiveId
    order by Timestamp asc 
     | scan declare(ActiveOps: string) with (
        step start output=none: Event has_any("SessionStart", "SessionReset") => ActiveOps = "";
        step ops output=none: Event has_any("OpStart", "OpCompleted") => ActiveOps = 
        iff(Event == "OpStart", strcat(ops.ActiveOps, " ", OperationId), 
        iff(Event == "OpCompleted", replace_string(ops.ActiveOps,OperationId,''),
        step send: Event has "MessageSend" => ActiveOps = ops.ActiveOps;
| project Timestamp, DetectiveId, Message, OperationId=ActiveOps

So now we have a string of active operation ids at each Send event. Now we need to split this string into operation ids, find the token for each, concat them and then call the Dekrypt function from Episode 8.

| mv-expand split(OperationId, " ") to typeof(string)
| join kind=inner (LogsEx | where isnotempty(Token)) on OperationId 
| sort by DetectiveId, Timestamp1 asc, Message, Timestamp
| summarize Key=make_list(Token) by DetectiveId, Timestamp, Message
| parse Message with "Sending an encrypted message, will use Dekrypt(@'" Encrypted "'," *
| project Message=Encrypted, Key=strcat_array(Key, "")
| invoke Dekrypt()

And as a final log hunt, let’s dump the less interesting ones and see what’s left

| where Result !has "Packing" and Result !has "Kuanda"

So it looks like Kuanda’s servers have a fatal issue that can be triggered by the right user_input.

It took me a while to realise that the detective id is my cluster id (that was staring at me from the ADX webui…), but the user_key, hmm…

After literally digging through the internals of hash_many and the xxhash64 algorithm to work out what the user_input might be, I decided to give up amd brute force it.

The Kuanda bug message included tostring() so guessing that the input might be an int, I hacked together this (expanding the range several times to eventually get a hit).

range user_answer from 0 to tolong(2.2E7) step 1
| where bitset_count_ones(hash_many('kvctw4a47fx6xkuuzy7v67', tostring(user_answer))) > 54

Boom. Kuanda defeated!

Note from future me: don’t try crime fighting on Eurostar, the wifi isn’t great. Apologies for any typos, solving this case on an ipad without a keyboard and roaming on 4G in Europe made for an interesting experience!

Until next year, adieu!