BTPanel, also known as BaoTa Panel or Pagoda Panel, is a web administration panel designed for use in the People’s Republic of China. The manufacturer’s website (bt[.]cn) claims 15 million active installations and 2 million “registered users,” including “more than 1000 government and enterprise units.” It’s similar to cPanel, providing a web interface for a machine admin to perform various system and service management tasks. Out of curiosity, I decided to take a deeper look at it.
Overview
BTPanel comes in two major variants. The first, BTPanel itself, is only intended to be available from within the People’s Republic of China. Fortunately for us, Google Cloud Platform has a region in Hong Kong, and the installer will fetch BTPanel and all its related components. While the rest of this post is based on the Linux version, there is also a Windows version that I did not look into. Both BTPanel and aaPanel are written primarily in Python using the Flask web framework, with some C code for asynchronous task execution.
Both BTPanel and aaPanel are listed on the SSD Secure Disclosure scope.
The following vulnerabilities are in demand:
-
- Preauth code/command execution
-
- Authentication bypass
aaPanel has a similar scope, with the in-demand vulnerabilities being:
-
- Preauth code/command execution
-
- Authentication bypass
Instructions for installing BTPanel are in the official documentation for Tencent Cloud, Huawei Cloud, and are available as a product on the AWS Marketplace.
The second variant, aaPanel, is made available to the rest of the world. The source code for aaPanel can be found on GitHub. While there are some differences we observed between the variants, they were fairly minor and, importantly, did not impact any of the interesting findings. High-profile customers of BTPanel, as posted on their website, include:
-
- China Entry-Exit Inspection and Quarantine Association
-
- Hunan Science and Technology Museum
-
- Nanjing Audit University
Installer
The installer is available at hxxp://download.bt.cn/install/install_panel.sh. A word of caution — it makes a lot of network requests to bt[.]cn, so if you don’t want your public IP address to be associated with the installation of BTPanel you need to use a VPN or proxy.
One of the first things the installer does is determine the best “download node.” This is a two-part check, first checking if the user is in the PRC by making a request to hxxps://api.bt.cn/api/isCN. If the user is in the PRC according to that API, it will select the lowest latency node from this list:
-
- hxxps://dg2.bt.cn
-
- hxxps://download.bt.cn
-
- hxxps://ctcc1-node.bt.cn
-
- hxxps://cmcc1-node.bt.cn
-
- hxxps://ctcc2-node.bt.cn
-
- hxxps://hk1-node.bt.cn
If the user is not in the PRC, it will select the lowest latency node from the above list, plus the following nodes:
-
- hxxps://na1-node.bt.cn
-
- hxxps://jp1-node.bt.cn
-
- hxxps://cf1-node.aapanel.com
Using that download node, the installer will:
-
- Fetch
$download_Url/init/systemd/btpanel.service, installing it as
/etc/systemd/system/btpanel.service, and enabling it.
- Fetch
-
- Download some
pip.txtfiles, then install them usingpip install -r.
- Download some
-
- Download and unpack
panel.zipto/www/server/panel.
- Download and unpack
Following that, it will change firewall rules to allow a lot of traffic in, using ufw, iptables, or firewall-cmd as appropriate. The full list of allowed ports are:
-
- 20
-
- 21
-
- 22
-
- 80
-
- 443
-
- The randomly generated panel port
-
- The SSH port configured in
/etc/sshd_config
- The SSH port configured in
-
- Ports 39000–40000
Commented out in the installer is the installation of OneAV, a PRC-based security product. It’s unclear if this is a bundled product or an optional add-on, since it was commented out of the installer I downloaded.
wget -O oneav_bt.sh hxxps://download.bt.cn/install/plugin/oneav/install.sh > /dev/null 2>&1 bash oneav_bt.sh install > /www/server/panel/install//btinstall.log 2>&1 rm -f oneav_bt.sh
Initial Analysis
As previously mentioned, the web panel is primarily written in Python using the Flask web framework. The “main” file, with all the Flask routes, is BTPanel/__init__.py. That file manually adds the class directory to the system path and then imports modules from that directory as needed.
Before using the web panel, the server must be “bound” to a Baota account. If you, like me, don’t have the required resources to create a Baota account, you can fake this check by creating a file data/userInfo.json with the following content:
{
"uid": "1",
"access_key": "123456",
"username": "admin",
"serverid": "1",
"o": "oemName",
}
Authentication
Authentication checks are, via a somewhat circuitous route, performed by the panelAdmin.check_login function in class/common.py and request_post inclass/userlogin.py. Some notable features of the authentication system:
-
- Validation that the user agent of a request matches the user agent of the session. This is done by comparing the MD5 of the current request’s User Agent against the previously observed User Agent. As a result, if you steal an authentication secret, you’d also need to know the legitimate user agent.
-
- The username and password are encrypted in transit using a per-session RSA key that is made available to the login web page Javascript. If I had to guess, this is designed to prevent MITM attacks from stealing the password when HTTPS is not used.
Login is performed via a POST request to /login, handled by the login method in BTPanel/__init__.py. After some initial checks to ensure that all expected parameters are present, execution is passed to the request_post method in class/userlogin.py.
There are two types of login: local, where the provided username and password are compared against the local database, and remote, where a provided tmp_token is checked against the current API config (config/api.json).
After a single authentication error, the user is required to include a CAPTCHA code. The codes are generated using a sample of 4 characters from a set of 57 unique characters. Because the actual keyspace that gets sampled has each uppercase letter 3 times, lowercase letters once, and only digits 3–9, the code generated won’t be uniformly random, but it’s probably good enough to prevent online brute-forcing attacks given that both the username and password are also randomly generated by default.
Multifactor authentication (MFA) is supported using time-based one-time passwords (TOTP). Once a source IP address has successfully completed a TOTP challenge, it is exempted from the MFA requirement for a day. This may result in unintentionally broad MFA exceptions if the panel is deployed behind a reverse proxy, or if users are behind a NAT.
CSRF
CSRF protection is implemented using two 48-character random tokens, uniqueper session. One token, stored as a client-side cookie request_token, is expected to be included as both a cookie and as the value of the X-Cookie-Token header for all “normal” requests, or just as a cookie for WebSocket requests. The other token, which gets loaded into the DOM as a hidden element with ID request_token_head, is expected to be the value of the x-http-token header for all requests.
CSRF checks are performed explicitly in the panel_other route handler, and implicitly in the publicObject method of BTPanel/__init__.py. While the publicObject method is used for many routes, it isn’t used in every route, meaning that not all API calls will be protected against CSRF.
Multi-MFA
One of the “advanced” authentication options supported by both BTPanel and aaPanel is a time-based one-time password (TOTP), as commonly used via authenticator apps like Google Authenticator. Their implementation is flawed, as every user ends up with the same multi-factor authentication code. See if you can spot the bug in userlogin.py:
import pyotp
secret_key = public.readFile(_key_file)
if not secret_key:
return public.returnJson(False, "没有找到key,请尝试在命令行关闭谷歌验证后在开启"), json_header
t = pyotp.TOTP(secret_key)
result = t.verify(post.vcode)
Nowhere in the code is a per-user secret retrieved or derived from a common secret. Instead, the same secret is used for all users. In effect, every user would see the same TOTP code if they looked at their authenticator apps at the same time.
For an attacker, this can be leveraged to bypass MFA by fully compromising any user of a given panel instance or by registering a new user for TOTP MFA. In either case, they would then be free to authenticate as other users, provided they know the username and password, supplying the ill-gotten MFA code.
Bad Session Secrets
The Flask session secret is predictably generated from the uname system boot time. The name of the session cookie is also MD5 hash of the session secret, so an attacker could brute force the session secret offline and then manufacture their own session cookies.
app.secret_key = public.md5(
str(os.uname()) + str(psutil.boot_time())
) # uuid.UUID(int=uuid.getnode()).hex[-12:]
local_ip = None
my_terms = {}
app.config["SESSION_MEMCACHED"] = SimpleCache(1000, 86400)
app.config["SESSION_TYPE"] = "memcached"
app.config["SESSION_PERMANENT"] = True
app.config["SESSION_USE_SIGNER"] = True
app.config["SESSION_KEY_PREFIX"] = "BT_:"
app.config["SESSION_COOKIE_NAME"] = public.md5(app.secret_key)
app.config["PERMANENT_SESSION_LIFETIME"] = 86400 * 30
The application uses sever-side session management, so the attacker would need to know a valid session ID to turn this into a useful attack. Incidentally, the installer also sometimes sends the exact information used to generate the secret key to bt[.]cn at installation:
curl -sS --connect-timeout 3 -m 3 --request POST \
--url "http://api.bt.cn/bt_error/index.php" \
--data "UID=XXXXX" \
--data "PANEL_VERSION=9.3.9" \
--data "REQUEST_DATE=${TIME}" \
--data "OS_VERSION=${SYS_VERSION}" \
--data "REMOTE_ADDR=192.168.X.X" \
--data "REQUEST_URI=panel" \
--data "USER_AGENT=${SYS_INFO}" \
...
Where SYS_VERSION and SYS_INFO can be used to assemble the uname string.
Buffer Overflows
There is at least one stack buffer overflow in the task executor, written in C. In the function for getting the PID of the running panel, get_panel_pid, a buffer of 16 bytes is allocated for the PID string. The function that fills that buffer, read_file, copies up to 128 bytes into the 16-byte buffer.
Here’s the source code of the relevant functions, from the aaPanel GitHub.
int get_panel_pid() {
//pid_file路径
char pid_file[64];
int str_len = str_join(pid_file, 2, panel_path, "/logs/panel.pid");
//读取pid文件
if (!file_exists(pid_file)) return 0;
char pid_str[16];
char mode[] = "r";
read_file(pid_file, mode, pid_str);
if (strlen(pid_str) == 0) return 0;
//转换为整数
int pid = atoi(pid_str);
return pid;
}
void read_file(char * filename,
const char * mode, char * fbody) {
int buff_size = 128;
char buff[buff_size];
FILE * fp = NULL;
fp = fopen(filename, mode);
if (!fp) return;
// //重置游标
rewind(fp);
fbody[0] = ‘\0’;
//读取文件内容
while (fgets(buff, buff_size, fp) != NULL) {
strncat(fbody, buff, strlen(buff));
}
fclose(fp);
}
The only other use of read_file is in the function get_panel_version, which uses stat to check the size of the file before reading it, and then allocate a heap buffer of that size. While better, that’s still not perfect. stat can be manipulated to return a smaller size than the actual file by using a pipe/socket, or by racing the time between the stat call and the file read.
int get_panel_version(char * version) {
char comm_file[] = "/www/server/panel/class/common.py";
if (!file_exists(comm_file)) return 0;
struct stat st;
stat(comm_file, & st);
char * comm_body = (char * ) malloc(st.st_size);
read_file(comm_file, "r", comm_body);
char * p = strstr(comm_body, "version = ");
if (p == NULL) {
free(comm_body);
return 0;
}
p += 11;
char * p2 = strstr(p, "'\n");
if (p2 == NULL) {
free(comm_body);
return 0;
}
int len = p2 - p;
strncpy(version, p, len);
free(comm_body);
return 1;
}
In either case, this is technically exploitable but not in a way that’s useful to an attacker. Both of the files being read are owned by `root` within directories owned by `root`, so an attacker would need to already have root access to the machine running the web panel. That said, we believe that there are similar buffer overflow issues within code reachable from web APIs that may be worth further investigation. For example:
-
- In
bt-task.c, thestart_file_taskfunction reads all unexecuted tasks into a stack array of length 10. If there are more than 10 tasks, this probably overflows the stack buffer.
- In
-
- In
bt-task.c, theget_file_task_listfunction directly copies database string values into set-length buffers without bounds-checking. If you can control the database, you can overflow the stack buffers.
- In
-
- In
bt-task.c, thesess_expirecopies the full path of files within the sessions directory into a 256-byte buffer. Since filenames themselves can be up to 255 bytes long, control of the sessions directory would lead to stack buffer overflow.
- In
-
- In
bt-task.c, the_zipfunction copies multiple variable-length strings into a fixed-length buffer. The input data is from scheduled tasks.
- In
Insecure Fetch
In multiple places, the panel fetches and executes or installs code via plaintext HTTP. This is a security risk, as an attacker with a MITM position can alter the content and gain remote code execution on the host as `root`. Specific instances of this include:
-
- The terminal admin CLI fetches
hxxp://download.bt.cn/install/update6.shand pipes it tobash. This would get run as the root user.
- The terminal admin CLI fetches
-
- In certain configurations, plugins and other installers are fetched from Various IPs/hosts using plaintext HTTP instead of HTTPS. The oddest of This is that
class/ssh_authentication.pydownloads and installs a PAM module fromhxxp://download.bt.cn/btwaf_rule/pam_python_so/for use in SSH authentication to the host.
- In certain configurations, plugins and other installers are fetched from Various IPs/hosts using plaintext HTTP instead of HTTPS. The oddest of This is that
-
- API interactions with AliCloud for managing DNS records are done in plaintext in two of the four(!) classes written to support LetsEncrypt and ACME certificate issuance.
-
- An SQL config validator (
CheckMyCnfinclass/public.py) fetches and applies an SQL config template over HTTP. The webshell checker (checkinclass/projectModel/bt_check_shell) uploads the file being checked tohxxp://w-check.bt.cn/check.php. As a side note, the checker will always return “safe” for any file larger than 1MB.
- An SQL config validator (
Conclusion
BTPanel, and its closely related open-source cousin aaPanel, is a widely deployed web administration panel with some significant “security code smells.” The local MFA implementation is fundamentally flawed, the session secret is not securely generated, and the task execution engine has a fair number of memory safety issues. The general state of the code base leads me to believe that motivated security researchers will be able to find more vulnerabilities in both aaPanel and BTPanel, which the SSD Secure Disclosure program may be willing to pay for.
The Researcher
Michael Torres is a security engineer with over a decade of experience in reverse engineering, security operations, and vulnerability development. He also posts on his blog at https://blog.sectorr.dev