← Back to list

Kusto Detective Agency Season 3 — Call of the Cyber Duty — Case 4— Dance with Shadows

Case Description

Phong · 2025-06-24 09:47 · 3 claps · 8.3 min read
#kql #kda #kusto-detective-agency #azure-data-explorer
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud

Kusto Detective Agency Season 3 — Call of the Cyber Duty — Case 4— Dance with Shadows

Case Description

Hi there,

It’s me this time — your old pal, El Puente. I’ve been watching you, and I must say: you’re getting close. Very close. Congratulations are in order — not many make it this far, and fewer still with such precision. The way you traced the signals, pieced it all together, brought yourself just a breath away from GreyNet… brilliant work. Truly. The funny thing? GreyNet is something in our common interest. I want in. And you should want it too.

Why? Because. It’s the data-market of all hackers. More valuable than a vault in a Swiss Bank holding all the gold in the world. Why, you ask? Because — most probably — it has the code for that vault… and a thousand others. Protected and managed by a 360-firewalled chatbot… Rumors say it was deployed only once, source code: obliterated — and… so was the programmer. b’1' (true) story.

But hey, there’s more. Listen carefully. There’s a thing — deep in the data shadows of GreyNet. Whispers call it a ‘quantum key’ or the Ultimate Universe Password. Sounds like sci-fi, right? But this is real. You know what’s unreal? Lior Suchard is involved. Yeah — that Lior. Mentalist. Illusionist. Full-on brain hacker. He pulled you in, didn’t he? Ironically, he may be the only way out. Rumors say he’s safeguarding this key in GreyNet’s heart.

I can get you into GreyNet. That’s right. I know the door. I know the knock. But — there’s a catch. Before I open that door, I need you to do something for me. Well… for us. There’s a data artifact — legendary stuff — about 30K+ records of pure magic. It’s the soul of the GreyNet Chatbot they’re running.

That dataset was so valuable, the Ultimate Hacker Committee (yeah, still a terrible name) fought — (well, argued) over a conference call — about who gets to control it. What’s wild? Over a hundred Primary Megahacker Communities (PMCs) joined in — each from a different city. And, as you know, not every city has a PMC. Only those cities with 256+ certified hackers earn the status. The call spun on for over an hour — which is basically forever in hacker-time. Everyone wanted a piece. But eventually, they reached a decision: No one should hold the whole thing. So they split it. Five groups. Five shards. Broken apart like the crown jewels of the Net — right there, live, on the channel.

Luckily, I had someone watching. Not the call itself — too risky. We had a tap on their chat network — think Discord, but darker, and made for people who speak fluent obfuscation. We’ve been capturing all their network traffic for months — but we had to keep it minimal: client IPs, timestamps, event types. No messages. No files. No attachments. But it’s something. And perhaps with your KQL skills… it’s just enough. Somewhere in that chaos, there’s a timestamp — a fleeting moment where the dataset still existed whole. One source IP. One opportunity — before it was scattered like digital ash.

Find it. Find that moment. Find the IP where the complete dataset file last existed. Because if you do — we can reconstruct the original. Remember that secret storage backup service that can recreate the file using a URL?

https://2025storagebackup.blob.core.windows.net/d{yyyy-MM-dd-HH-mm}/{IP}/{filename}

Well, good that you remember… because they didn’t. And that’s our chance. Find the date. Find the IP. Bring me The URL. And then, my friend… we’re going Grey. The real Grey. Not the shadows you’ve seen — but the core. The place where secrets dream.

I’ll be waiting on the other side of the hash.

— El Puente

Goal: find the Secret artifact backup link

Let’s get into the log

.execute database script <|
// Hackers Chats Server Logs
.create-merge table ChatServerLogs (Timestamp:datetime, ClientIP:string, EventType:string, Properties:dynamic)
.ingest async into table ChatServerLogs (@'https://kustodetectiveagency.blob.core.windows.net/kda3c04/chat_logs_00000.csv.gz')
.ingest async into table ChatServerLogs (@'https://kustodetectiveagency.blob.core.windows.net/kda3c04/chat_logs_00001.csv.gz')
.ingest into table ChatServerLogs (@'https://kustodetectiveagency.blob.core.windows.net/kda3c04/chat_logs_00002.csv.gz')
// Data about cities in the world, the Geo area (polygon) and the hackers group estimation
.create-merge table Cities (City:string, Area:dynamic, EstimatedHackersCount:int)
.ingest into table Cities (@'https://kustodetectiveagency.blob.core.windows.net/kda3c04/cities.csv.gz')
// IP ranges (CIDR) and their Geo locations (Longitude, Latitude)
.create-merge table IpToLocation (IpCidr:string, Lon:double, Lat:double)
.ingest into table IpToLocation (@'https://kustodetectiveagency.blob.core.windows.net/kda3c04/ip_to_location.csv.gz')

This case we have 3 table, it seems little trickier. Let’s explore them first

ChatServerLogs
| summarize Records=count(), FirstLog=min( Timestamp), LastestLog = max(Timestamp)  

ChatServerLogs — datainfo

ChatServerLogs — datainfo

ChatServerLogs — Action Type

ChatServerLogs — Action Type

Notable EventType would be FileReceived. Look like it can help us reconstruct the url. Let’s browse it

ChatServerLogs
| where EventType == 'FileReceived'
| evaluate bag_unpack(Properties)
| sample 20

Look like it contains random filename and sent from different source and data doesnt seem to be spike in totable BytesSent. From the description we observed following hints:

One Source IP

PMC: Only those cities with 256+ certified hackers earn the status.

The call spun on for over an hour

File is spliited into 5 files, live

which means filter for EstimatedHackersCount >255 in Cities table. In order the enrich ClientIP in ChatServerLogs table.

First: we do ipv4_lookup as we did in Case 3

| evaluate ipv4_lookup( IpToLocation,ClientIP,IpCidr)

but the data in Cities table is polygon data. How do we lookup and enrich our data? KQL have handy function called “geo_polygon_lookup”. Along with previous logic we have following

| evaluate ipv4_lookup( IpToLocation,ClientIP,IpCidr)
| evaluate geo_polygon_lookup(Cities, Area, Lon, Lat, return_lookup_key = false)

Next, how do we know which call has over ONE hour duration. In order to find out, we have to sort the data and scan them. KQL has very powerful operator called “scan” whichh scans data, matches, and builds sequences based on the predicates.

Already too much theory, let’s dive into data

ChatServerLogs
| partition hint.strategy=native by ClientIP
(
    sort by Timestamp asc
     | scan with_match_id=id  declare(LogoutTime:datetime, LoginTime:datetime,sessionDuration:timespan  ) with
     (
        step Login output=none: EventType =="Login" 
            =>LoginTime=Timestamp;
        step Logout: EventType=="Logout" 
            => LoginTime= Login.LoginTime, LogoutTime = Timestamp, sessionDuration = Timestamp - Login.Timestamp;
     )
)

Above query, we group the log on ClientIP by using partition. then we use sort it and scan series see if any match our logic:

First it finds login even in “ClientIP A” then it find next logout event in “ClientIP A”. After that calculate the time between Login and Logout then we know how long the login last.

Next, we merge this logic with PMC filter can call it LongCallPMCIP

let LongCallPMCIP = 
ChatServerLogs
| partition hint.strategy=native by ClientIP
(
    sort by Timestamp asc
     | scan with_match_id=id  declare(LogoutTime:datetime, LoginTime:datetime,sessionDuration:timespan  ) with
     (
        step Login output=none: EventType =="Login" 
            =>LoginTime=Timestamp;
        step Logout: EventType=="Logout" 
            => LoginTime= Login.LoginTime, LogoutTime = Timestamp, sessionDuration = Timestamp - Login.Timestamp;
     )
)
| where sessionDuration > 1h
| evaluate ipv4_lookup( IpToLocation,ClientIP,IpCidr)
| evaluate geo_polygon_lookup(Cities, Area, Lon, Lat, return_lookup_key = false)
| where EstimatedHackersCount >255
;

Next, we see the hint say big file is being spited by 5 shards. Hmmm, Let’s see if they are same size

ChatServerLogs
| where EventType =="FileReceived"
| evaluate bag_unpack(Properties)
| summarize dcount(FileName) by tostring(BytesSent)
| summarize count() by dcount_FileName

Look like they are not divided equally into 5 files with same size. Based on the query result, only one case that two FileName has same BytesSent. Next, we are going to check if same file got sent multiple time

ChatServerLogs
| where EventType =="FileReceived"
| evaluate bag_unpack(Properties)
| summarize SentAttempt=count(),dcount(ClientIP) by FileName
| summarize Count=count() by SentAttempt, dcount_ClientIP

Now we can assume, either 5 files are same name same size Or 5 files has different name different size. While filtering if any file has file size equal to file sent 5 times above.

let susfile=
ChatServerLogs
| where EventType =="FileReceived"
| evaluate bag_unpack(Properties)
| summarize SentAttempt=count() by tostring(BytesSent)
| where SentAttempt ==5
| extend BytesSent= todouble(BytesSent) * 5
;
ChatServerLogs
| where EventType =="FileReceived"
| evaluate bag_unpack(Properties)
| where todouble( BytesSent) in (( susfile | distinct BytesSent))

No result return. Therefore, the theory would be leaning to 5 fives same name same size but total size would be greater than original size OR the original file never been transferred => no original file log.

There is a hint that these files are divided “right there, live, on the channel”. Let’s bin those log see if any time slot has 5 files transferred.

let susfile=
ChatServerLogs
| where EventType =="FileReceived"
| where ClientIP in (( LongCallPMCIP| distinct ClientIP))
| evaluate bag_unpack(Properties)
| summarize SentAttempt=count(),dcount(ClientIP) by FileName
| where SentAttempt ==5
;
ChatServerLogs
| where EventType =="FileReceived"
| evaluate bag_unpack(Properties)
| where FileName in (( susfile | distinct FileName ))

Ok, this give quite a lots of groups around 19 distinct group of FileName

Now, let twork on our first theory “5 fives same name same size but total size would be greater than original size”. Let’s query file these SourceIP receive before sending out 5 files. turn previous query to SusLongCallSourceIP.

let susfile=
ChatServerLogs
| where EventType =="FileReceived"
| where ClientIP in (( LongCallPMCIP| distinct ClientIP))
| evaluate bag_unpack(Properties)
| summarize SentAttempt=count(),dcount(ClientIP) by FileName
| where SentAttempt ==5
;
let SusLongCallSourceIP =
ChatServerLogs
| where EventType =="FileReceived"
| evaluate bag_unpack(Properties)
| where FileName in (( susfile | distinct FileName ))
;

Next we query what those SourceIP received

ChatServerLogs
| where EventType =="FileReceived"
| evaluate bag_unpack(Properties)
| where ClientIP in (( SusLongCallSourceIP| distinct SourceIP)) or BytesSent in (( SusLongCallSourceIP | distinct FileName)) // see what those SourceIPrecive
// Some little data mofication for easy filter
| extend SourceIP = iff(BytesSent !in (( longcallsusfile | distinct BytesSent)) and SourceIP !in(( longcallsusip | distinct SourceIP)) ,ClientIP, SourceIP),
EventType = iff(BytesSent !in (( longcallsusfile | distinct BytesSent)) and SourceIP !in(( longcallsusip | distinct SourceIP)) ,'PotentialOrgFileReceived', EventType)
| join kind=leftouter  susfile on SourceIP
| where Timestamp < LastsplitRev //OrgFile came before Lastsplit was sent
| project-away BytesSent1,SourceIP1, dcount_ClientIP, dcount_FileName
| extend IsBiggerThanSumbs= iff(BytesSent !in (( longcallsusfile | distinct BytesSent)) and todouble(BytesSent)>sumbs,1,0)
| where IsBiggerThanSumbs== 0

When I reach this stage the result still alot around 115 records, and I couldn’t guess or filter what is the original file. Some sourceIP received many file before sending 5 shards out. Look like I’m running into rabbit hole.

I decide to move on to second theory “the original file never been transferred => no original file log.” the filename in backup link would be same name as those 5 files.

We keep “susfile” and “LongCallPMCIP” as those logic are not changed. Now, we get all ClientIP who received “5 shards” then enrich (join) data with LongCallPMCIP in order to get logic of “5 shards received during long call”

ChatServerLogs
| where EventType =="FileReceived"
| evaluate bag_unpack(Properties)
| where FileName in (( susfile | distinct FileName ))
| join kind=inner LongCallPMCIPon ClientIP
| where LogoutTime - Timestamp between ( 30s ..  sessionDuration)
| project Timestamp, ClientIP, BytesSent, FileName, SourceIP, LogoutTime, LoginTime, sessionDuration
| summarize dcount(ClientIP), dcount(SourceIP) by FileName

The result is very ideal, Two FileName.

To make sure, I’m not making typo while formatting url for answer, I removed last summarize line and added following:

| extend formatted = strcat(@"https://2025storagebackup.blob.core.windows.net/d", format_datetime(Timestamp, "yyyy-MM-dd-HH-mm"),@"/",SourceIP,@"/",FileName)

I tried 2 urls and the second one is correct.

https://2025storagebackup.blob.core.windows.net/d2025-05-28-09-14/12.236.95.40/vv0q2rd12th.csv.gz
https://2025storagebackup.blob.core.windows.net/d2025-05-07-11-10/80.237.254.8/ca2m3h28hlo.csv.gz

If you have some nice query feel free to share in comment 😊


메타데이터
post_id
77aee142a893
slug
kusto-detective-agency-season-3-call-of-the-cyber-duty-case-4-dance-with-shadows-77aee142a893
url
https://medium.com/@Phonggg/kusto-detective-agency-season-3-call-of-the-cyber-duty-case-4-dance-with-shadows-77aee142a893
canonical_url
https://medium.com/@Phonggg/kusto-detective-agency-season-3-call-of-the-cyber-duty-case-4-dance-with-shadows-77aee142a893
author_url
https://medium.com/@Phonggg
status
ok
fetched_at
2026-06-25 12:15:08