QNAP QSA-21-25 : Helpdesk - A simple user without privileges can gain administrative access on the NAS
Timeline (DD/MM/YYYY)
- 04/12/2020 : Bug sent to QNAP security team
- 07/12/2020 : QNAP confirms the report reception
- 30/01/2021 : Update requested on bug status
- 01/02/2021 : QNAP confirms the bug
- 08/04/2021 : Fix is published with Helpdesk 3.0.4
- 11/06/2021 : Security advisory published on the QNAP website
Summary
A simple user can access dangerous endpoints on HelpDesk allowing to create a _qnap_support account, get his password and activate his account on the system. The created account is in the administrators group. SSH access with administrators privileges can be enabled with this account.
Preparation
System and package upgrade
First, we need to ensure that the system and HelpDesk package are up to date.
User Account Creation
To exploit the vulnerability, we need user credentials. We can create a user “exploit” with password “MA7adCdS”. This user doesn’t require any privileges.
Exploitation
To exploit the vulnerability, we need a few steps:
- Connect with a valid user
- Access /apps/qdesk/api/v1/remote_access/getkeys URL to generate a new support account
- Access /apps/qdesk/api/v1/remote_access/enable to enable this account
- At this step, the support account is usable in the web interface
- Add _qnap_support user to ssh group
- Connect to SSH with this user
Explanation
The first problem is that dangerous entry points are not restricted to the administrator. Indeed, the user doesn’t need access to :
- /apps/qdesk/api/v1/remote_access/getkeys
- /apps/qdesk/api/v1/remote_access/enable
Then, the “enable” endpoint, need two POST parameters :
- ticketId
The ticketId is verified by making a request on https://help.qnap.com/apps/qdesk_service/api/v1/qpkg/remote/{ticketId}
// www/App/Models/RemoteAccessModel.php
public function getRemoteSessionStatus()
{
$curlUrl = 'https://' . config('helpdesk_server') . "/apps/qdesk_service/api/v1/qpkg/remote/{$this->ticketId}";
$srvOutput = curlWrapper('GET', $curlUrl);
if (is_array($srvOutput)) {
$srvOutput = json_encode($srvOutput);
}
$srvOutput = json_decode($srvOutput);
if (isset($srvOutput->status) && $srvOutput->status === 'FALSE') {
if (isset($srvOutput->result->errCode) && $srvOutput->result->errCode == 28) {
return array('status' => '408', 'msg' => $srvOutput->result->errMsg);
} else {
return $srvOutput->status;
}
}
$ticketStatus = $srvOutput->status;
if ($ticketStatus == 0) {
$ticketStatus = $srvOutput->data->status;
}
return $ticketStatus;
}
If the “ticketId” variable contains a new line character, “$srvOutput” will be NULL
. All checks in the enable function will fail to catch the error.
// www/App/Controllers/Api/RemoteAccess.php
public function enable()
{
[...]
if (is_array($remoteStatus)) {
$objRStatus = json_encode($remoteStatus);
$objRStatus = json_decode($objRStatus);
if (isset($objRStatus->status)) {
Response::error($objRStatus->status, apiFormater($objRStatus->msg, 2014));
}
}
if ($remoteStatus == 1001 || $remoteStatus == 1002) {
$this->fileLogModel->logDebug('Remote access case is already enabled.', __FILE__, __LINE__);
Response::error(403, apiFormater(null, 2007));
}
if ($this->remoteAccessModel->isExist($ticketId, $email) && $remoteStatus == 1004) {
$this->fileLogModel->logDebug('Remote access case is already expired.', __FILE__, __LINE__);
Response::error(403, apiFormater(null, 2018));
}
if ($this->remoteAccessModel->isExist($ticketId, $email) && $remoteStatus == 1005) {
$this->fileLogModel->logDebug('Remote access case is already closed.', __FILE__, __LINE__);
Response::error(403, apiFormater(null, 2009));
}
if ($remoteStatus == 1) {
$this->fileLogModel->logError('Remote access case is not activate.', __FILE__, __LINE__);
Response::error(404, apiFormater(null, 2010));
}
// Fix SSHD config permission
$this->qtsModel->fixSSHDConfigPerm();
// Generate SSH Keypair
$privateKey = file_get_contents($this->tmpKeyPath);
$publicKey = shell_exec("/usr/bin/ssh-keygen -y -f $this->tmpKeyPath");
$pattern = '/[\\r\\n]/';
$pkWithTicketID = preg_replace($pattern, '', $publicKey) . ' ' . $ticketId . "\r\n";
$this->remoteAccessModel->resetAuthedKey();
file_put_contents($this->authedKeyPath, $pkWithTicketID, FILE_APPEND);
$base64SK = base64_encode($privateKey);
// Check and create QTS account
$tempAccount = $this->remoteAccessModel->getTempAccount();
$supportID = $tempAccount['tempId'];
$supportPW = $tempAccount['tempPw'];
$this->remoteAccessModel->removeQTSUserByAPI();
if ($this->remoteAccessModel->checkQTSUser()) {
$this->remoteAccessModel->removeQTSUser();
if ($this->remoteAccessModel->checkQTSUser()) {
$this->fileLogModel->logError('Can not remove QTS support account.', __FILE__, __LINE__);
Response::error(500, apiFormater(null, 2010));
}
}
$this->remoteAccessModel->qtsUserPW = $supportPW;
$po = $this->remoteAccessModel->createQTSUserByAPI();
if (!$this->remoteAccessModel->checkQTSUser()) {
$this->remoteAccessModel->createQTSUser();
if (!$this->remoteAccessModel->checkQTSUser()) {
$this->fileLogModel->logError('Can not create QTS support account.', __FILE__, __LINE__);
Response::error(500, apiFormater(null, 2011));
}
}
//set config to close popup
$this->remoteAccessModel->setQTSUserConfig();
// Get NAS http, https and ssh port
if (empty($httpPort) && empty($sshPort)) {
$httpPort = shell_exec("/sbin/getcfg 'System' 'Web Access Port' -d 8080");
$httpsPort = shell_exec("/sbin/getcfg 'Stunnel' 'Port' -d 443");
$sshPort = shell_exec("/sbin/getcfg 'LOGIN' 'SSH Port' -d 22");
$httpPort = str_replace("\n", '', $httpPort);
$httpsPort = str_replace("\n", '', $httpsPort);
$sshPort = str_replace("\n", '', $sshPort);
}
The function will crash after this code but the support account is enabled now.
Remediation
To fix these vulnerabilities, a few steps are required:
- Check admin permissions on required routes
- Verify the ticketId format with a regex