一场针对 Mosquitto MQTT 物联网消息协议安全认证以及存取控制的综合演练

物联网的安全非常重要,可惜很多厂商,甚至国内外的著名厂商实现的 MQTT 云平台存在着许多致命的安全漏洞。在我们日常的代码开发和系统运维中,有根安全的弦绷得比较紧的话,就能避免很多信息泄露,后门攻击的安全事故。

本文以 Mosquitto 消息服务器为例子,从传输层 TLS 安全加密,到 MySQL 数据库方式用户认证,以及 ACL 控制,来讲解 MQTT 消息协议整套的安全实现。

  1. TLS 的实现,不再详述,参见文章:物联网 MQTT 协议 Mosquitto 的 TLS 加密
  2. 数据库方式认证,参见文章: 让Mosquitto MQTT 服务器的用户认证插件采用明文密码

在以上数据库方式认证的参考文章中,我把 ACL 权限控制 auth_opt_aclquery 的选项注释掉了。

现在意识到这个其实也是必须的,否则所有的用户都必须是超级用户才能读写消息。而这也意味着一个用户只要能连接到消息服务器,就能 subscribe 到所有其他人的消息。而这必须在服务器端通过对 topic 读写的限制才能实现。

由于我们的用户名和密码是针对某个客户的,而一个客户下面可能有多个设备(box)需要读写。为此,我在 MySQL 上创建了一个视图:

create view v_mqtt_acls as (select c.mqtt_user as mqtt_user,concat(‘V/+/’,b.box_id) as topic, 2 as rw from customer c join box b on b.customer_id=c.id);

这样用户如果不是 superuser,就会根据 Mosquitto 的配置到这个视图来查找对指定的 topic 是否可以读写。

以下是 Mosquitto 配置文件关于数据库用户认证那部分的配置:

auth_plugin /etc/mosquitto/auth-plug.so
auth_opt_backends mysql
auth_opt_host localhost
auth_opt_port 3306
auth_opt_dbname mydb
auth_opt_user myuser
auth_opt_pass mypass
auth_opt_userquery SELECT mqtt_passwd FROM customer WHERE mqtt_user = ‘%s’
auth_opt_superquery SELECT IFNULL(COUNT(*), 0) FROM customer WHERE mqtt_user = ‘%s’ AND mqtt_superuser = 1
auth_opt_aclquery SELECT topic FROM v_mqtt_acls WHERE mqtt_user = ‘%s’

下面是完整的根据 Mosquitto-PHP 实现的对一个设备发送重启指令的代码。Mosquitto-PHP 的安装实现可以参考本站文章:用 PHP 通过 MQTT 物联网协议实现消息传送

<?php

$boxid = ‘Bp63C7’;

$username = ‘xxx’;
$password = ‘yyyyy’;
$broker = ‘mqtt.yj777.cn’;
$port = 8883;
$caPath = ‘/etc/mosquitto/ssl/ca-cert.pem’;
$clientID = ‘PHP-Mosquitto’;

$c = new Mosquitto\Client;

// 这段 contruct clientID 是必须的,否则会随机产生 clientID,导致 publish denied.
$c->__construct($clientID , true);
$c->setCredentials($username, $password);
$c->setTlsCertificates($caPath);
$c->setTlsInsecure(‘true’);
$c->setTlsOptions(Mosquitto\Client::SSL_VERIFY_PEER, ‘tlsv1’);

// $port 端口号也必须指定,否则连接 1883 端口去了。
$c->connect($broker,$port);

$cmd_restart = ‘command’;
$topic = ‘V/’.$cmd_restart.’/’.$boxid;
$msg_restart = ‘@{“id”:”‘.$boxid.'”,”timestamp”:’.time().’,”command”:”restart”}’;
$qos = 2;

$c->onConnect(function() use ($c) {
global $topic,$msg_restart,$qos;
$c->publish($topic, $msg_restart, $qos);
});

for ($i = 0; $i < 100; $i++) {
$c->loop(1);
}

echo “Finished\n”;

如果我们故意把 boxid 写错,就可以在 mosquitto log 里看到:

Denied PUBLISH from PHP-Mosquitto (d0, q2, r0, m1, ‘V/command/Bp63C7’, … (59 bytes))

综上而言,对 MQTT 安全的理解是一个螺旋前进的过程,在实践中多思考,就能发现问题,并解决问题。而代码通过安全认证,和非安全方式连接相比可能会有很多坑要踩。

通过MD5函数生成固定长度的 唯一 ID

我们知道 md5 是常用的 Hash 函数用于产生消息摘要,校验文件等。

但是在日常的操作中,记忆一个128位长度,或者32个16进制字符并不是一件容易的事情。

在实际针对某设备做逆向工程,取得 ID 时,发现输入和输出是一一对应的。固定的输入有固定的输出。我们采用 md5 函数,然后设置了一定的变换,取得了6位固定长度,大小写数字混合,第一个字符不为数字的算法。

<?php
$name = ‘abcd’;
$mac = ’00:12:34:56:78:ea’;
$md = md5($name.$mac);
// echo $md. ” length:”.strlen($md).”\n”;
$charset = ‘abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456789’;
$d = ”;
for ($i = 0; $i<6; $i++) {

// 取 md5 里的,每5个字符转换成10进制后对61求余数。
$c = substr($md,$i*5,5);
$cs = hexdec($c);

// $charset 故意设置成61位,是一个质数,去掉了数字0
$pos = $cs % 61;
// 第一个不是数字
if ($i == 0 and $pos > 51 ) $pos -= 30;
$d .=$charset[$pos];
}
echo $d.”\n”;

用 Node.js 把 Mosquitto MQTT 服务器上的消息存入 MySQL

首先需要安装相关的包: (cnpm 是npm 国内淘宝镜像,请参考前文如何安装 cnpm

#cnpm install mysql -g

#cnpm install mqtt -g

#cnpm install pm2 -g

如果运行代码遇到模块没找到的错误,需要运行

#cnpm link mqtt

第一次运行代码可以用

#node app_mqtt2mysql.js 来执行。如果没有任何输出,说明代码正常工作了。 按 Ctr-C 退出。

#pm2 start app_mqtt2mysql.js -i 1 -n mqtt

就可以让程序长驻内存。

#pm2 list 可以看到程序的运行状态。

查看 Mosquitto 的日志可以看到,有任何消息 /VV 下的主题 publish 到服务器时,YJ-MQTT都会收到,并把消息插入数据库,查看数据库表即可验证。

下面是完整的源代码:

# cat app_mqtt2mysql.js

var fs = require(‘fs’);

var mqtt = require(‘mqtt’);

var Topic = ‘VV/#’; //subscribe to all topics
var Broker_URL = ‘mqtt://mqtt.yj777.cn’;
var caFile = fs.readFileSync(‘/etc/mosquitto/ssl/ca-cert.pem’);

// Database
var Database_URL = ‘127.0.0.1’;

var options = {
clientId: ‘YJ-MQTT’,
protocol: ‘mqtts’,   // 加密的协议名需要写成 mqtts 。
protocolId: ‘MQIsdp’,
protocolVersion: 3,
secureProtocol: ‘TLSv1_method’,

// MQTT 服务器端设置了 require_certificate false,所以,我们这里只提供 CA 证书。
ca: caFile,
rejectUnauthorized: false,
port: 8883,
username: ‘username’,
password: ‘Passw0rd’,
keepalive: 60
// key: KEY,
// cert: CERT,
};

var client = mqtt.connect(Broker_URL, options);
client.on(‘connect’, mqtt_connect);
client.on(‘reconnect’, mqtt_reconnect);
client.on(‘error’, mqtt_error);
client.on(‘message’, mqtt_messsageReceived);
client.on(‘close’, mqtt_close);

function mqtt_connect() {
//console.log(“Connecting MQTT”);
client.subscribe(Topic, mqtt_subscribe);
};

function mqtt_subscribe(err, granted) {
console.log(“Subscribed to ” + Topic);
if (err) {console.log(err);}
};

function mqtt_reconnect(err) {
//console.log(“Reconnect MQTT”);
//if (err) {console.log(err);}
client = mqtt.connect(Broker_URL, options);
};

function mqtt_error(err) {
//console.log(“Error!”);
//if (err) {console.log(err);}
};

function after_publish() {
//do nothing
};

//receive a message from MQTT broker
function mqtt_messsageReceived(topic, message, packet) {
var message_str = message.toString(); //convert byte array to string
message_str = message_str.replace(/\n$/, ”); //remove new line
message_str = message_str.replace(/^@/, ”); //remove first \@
//payload syntax: clientID,topic,message
// if (countInstances(message_str) != 1) {
// console.log(“Invalid payload”);
// } else {
if (message_str) insert_message(topic, message_str, packet);
//console.log(message_arr);
// }
};

function mqtt_close() {
//console.log(“Close MQTT”);
};

////////////////////////////////////////////////////
///////////////////// MYSQL ////////////////////////
////////////////////////////////////////////////////
var mysql = require(‘mysql’);
//Create Connection
var connection = mysql.createConnection({
host: Database_URL,
user: “db_user”,
password: “db_pass”,
database: “db_name”
});

connection.connect(function(err) {
if (err) throw err;
//console.log(“Database Connected!”);
});

//insert a row into the tbl_messages table
function insert_message(topic, message_str, packet) {
// var message_arr = extract_string(message_str); //split a string into an array
var clientID = “”;
// var message = message_arr[0];
var message = message_str;
var sql = “INSERT INTO ?? (??,??,??) VALUES (?,?,?)”;
var params = [‘tablename’, ‘clientID’, ‘topic’, ‘message’, clientID, topic, message];
sql = mysql.format(sql, params);

connection.query(sql, function (error, results) {
if (error) throw error;
console.log(“Message added: ” + message_str);
// we can call the API to run sync_device.php
});
};

//split a string into an array of substrings
function extract_string(message_str) {
var message_arr = message_str.split(“,”); //convert to array
return message_arr;
};

//count number of delimiters in a string
var delimiter = “,”;
function countInstances(message_str) {
var substrings = message_str.split(delimiter);
return substrings.length – 1;
};

// Disable Console Log
// console.log = function() {}

让Mosquitto MQTT 服务器的用户认证插件采用明文密码

Mosquitto MQTT 服务器的认证插件默认采用 PBKDF2 单向加密存储密码。为了这个,还特别的升级 PHP 5.4 到 5.6。

但是,由于客户端(物联网边缘层网关)连接 MQTT 服务器的用户名和密码,动态的取自服务器,因此,取到的加密后密码,根本无法解密,从而使 MQTT 通信无法实现。

由于客户端网关的 MQTT 取用户名和密码的方式无法更改,所以,只好修改认证插件,让密码以明文保存,客户端也取到明文的密码,再连接到服务器端,就能通过认证。

下面分享编译,以及修改认证插件源代码的过程:

  1. Mosquitto 官方网站下载源码,并解压到 /usr/local/src 目录。
  2. 在 /usr/local/src 目录下运行: git clone https://github.com/jpmens/mosquitto-auth-plug.git
  3. cd mosquitto-auth-plug
  4. cp config.mk.in config.mk, 根据需要修改 config.mk :
    MOSQUITTO_SRC = /usr/local/src/mosquitto-1.4.14
    OPENSSLDIR = /usr
    BACKEND_MYSQL ?= yes
    如果后端数据库是 MariaDB,别忘了安装 mariadb-devel 包。
    修改 auth-plug.c,注释掉 pbkdf2 加密验证的那部分。
    if (phash != NULL) {
    match = pbkdf2_check((char *)password, phash);
    if (match == 1) {
    authenticated = TRUE;
    break;
    }
    }
    添加如下代码:
    if (!strcmp(phash, password)) {
    _log(LOG_DEBUG, “** Authenticated !”);
    authenticated = TRUE;
    break;
    }
    另外,如果不想在日志看到详细的 Log 信息,可以修改 log.c ,设置 log_quiet =1 。
    int log_quiet=0;
  5. 然后运行 make ,即可以在  mosquitto-auth-plug 目录下看到 auth-plug.so 文件
  6. mv auth-plug.so /etc/mosquitto
  7. vim /etc/mosquitto/mosquitto.conf
    # 注释掉原来以文件方式认证
    # password_file /etc/mosquitto/mosquitto.pwd

    # __ __ ____ ___ _
    # | \/ |_ _/ ___| / _ \| |
    # | |\/| | | | \___ \| | | | |
    # | | | | |_| |___) | |_| | |___
    # |_| |_|\__, |____/ \__\_\_____|
    # |___/

    auth_plugin /etc/mosquitto/auth-plug.so
    auth_opt_backends mysql
    auth_opt_host localhost
    auth_opt_port 3306
    auth_opt_dbname dbname
    auth_opt_user mqtt_db_user
    auth_opt_pass  mqtt_db_password
    auth_opt_userquery SELECT mqtt_passwd FROM related_table WHERE mqtt_user = ‘%s’
    auth_opt_superquery SELECT IFNULL(COUNT(*), 0) FROM related_table WHERE mqtt_user = ‘%s’ AND mqtt_superuser = 1

    # auth_opt_aclquery SELECT topic FROM acls WHERE username = ‘%s’
    #
    # auth_opt_backends cdb,mysql
    # auth_opt_cdbname pwdb.cdb
    #
    # # Usernames with this fnmatch(3) (a.k.a glob(3)) pattern are exempt from the
    # # module’s ACL checking
    # auth_opt_superusers S*

用 yum 升级 CentOS 7 上的 PHP5.4 到 PHP 5.6

相比较于 PHP54, PHP 5.6 在函数功能上,性能上都有大的提升。

整个安装过程十分简单:

参考: https://webtatic.com/packages/php56/
rpm -Uvh https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm

rpm -Uvh https://mirror.webtatic.com/yum/el7/webtatic-release.rpm

yum erase -y php-common

yum -y install php56w php56w-mbstring php56w-gd php56w-mcrypt php56w-mysql php56w-devel php56w-xml

然后重新启动 Apache 即可。
如果 Web 服务器是 Nginx 那么还要安装 php56w-fpm,然后重新启动 Nginx。
命令行下,可以用 php -v 查看版本。
建议在 Web DocumentRoot 下创建一个 4yi.php 内容:
<?php
phpinfo();

然后在浏览器访问这个 4yi.php 可以查看 PHP 以及各支持模块的版本信息。

看完后,删除这个 4yi.php ,以防敏感信息泄露。
也可以在命令行下以 php -i 查看。

为 proftpd 设置只能上传和改名,不能查看,下载,删除文件

我们可以在对应的用户目录下创建 .ftpaccess 文件,写入如下内容即可:

<Limit ALL>
DenyAll
</Limit>

<Limit CDUP CWD PWD XCWD XCUP>
AllowAll
</Limit>

<Limit STOR STOU RNFR RNTO>
AllowAll
</Limit>

这样就算ftp 用户名和密码泄露,也能保护服务器端的数据,客户端不能查看服务器上的文件名,就算猜测到了, 也不能下载。

具体的支持命令可以参见 Proftpd 官方命令列表

物联网 MQTT 协议 Mosquitto 的 TLS 加密

前文提到过 物联网消息协议-mosquitto-服务器在-centos-7-上的部署, 然而等要部署到云端服务器时,需要采用 TLS 来实现传输层的加密时,碰到了好几个坑。分享如下。

一、使用 Certbot (Let’s Encrypt)证书的坑。

  • 根据官网某帖子的描述,其实都是错的。正确的配置是:

cafile /etc/mosquitto/ssl/certbot-ca.pem
certfile /etc/mosquitto/ssl/cert1.pem
keyfile /etc/mosquitto/ssl/privkey1.pem

其中 certfile 和 keyfile 是 Certbot 产生的证书文件,而 cafile 的文件需要由 Let’s Encrypt的DST_Root_CA_X3.pem 根证书合并 fullchain1.pem 产生:

cat DST_Root_CA_X3.pem fullchain1.pem >certbot-ca.pem

要把这个文件也拷贝到 Mosquitto 客户端,在客户端运行 mosquitto_sub 或者 mosquitto_pub 时需要指定 -cafile certbot-ca.pem

  • 碰到所有证书文件载入错误的消息,Unable to load server key file, 或者 Unable to load CA certificates 一般是文件权限问题,因为 Certbot 的证书目录默认是 root 只读。
    drwx------ 9 root root 4096 Aug 11 16:09 archive
    所以需要手工拷贝证书到 mosquitto 用户可读的目录。基于这一点以及 Let’s Encrypt 证书每三个月需要更新的残酷现实,为了不给设备端添加更新证书的额外流程,最终放弃使用 Let’s Encrypt 证书,采用自己发行证书的办法。另外一个错得离谱的地方是,采用某网站的说法,在 cafile 后面加了等号,都是类似的错误,其实还是文件没有读到,Mosquitto 把等号也作为文件名的一部分了。 看日志即可。

二、自行发证书的坑

  •   这个其实也不能算坑,只要有在Web 服务器发行过证书的经验,就应该知道证书最好不要加密码,否则每次重启服务都需要输入密码。而 Mosquitto 是不支持输入密码的。 下面把流程大概介绍一下:

产生 CA 私钥,这里可以添加一个密码,来保护私钥。

openssl genrsa -out ca-key.pem -des 2048

生成 CA 证书请求文件CSR
openssl req -new -key ca-key.pem -out ca-csr.pem

用 CSR 生成 CA 证书 ca-cert.pem
openssl x509 -req -in ca-csr.pem -signkey ca-key.pem -out ca-cert.pem

产生服务器证书私钥(不要加密码) server-key.pem
openssl genrsa -out server-key.pem 2048

生成服务器证书请求文件 CSR,命令行里 -config 的配置文件可以不要,主要是为了节省每次输入那些CN/OU/Common Name等信息。
openssl req -new -key server-key.pem -config openssl.cnf -out server-csr.pem

生成服务器证书 server-cert.pem:

openssl x509 -req -CA ca-cert.pem -CAkey ca-key.pem -CAcreateserial -in server-csr.pem -out server-cert.pem -extensions v3_req -extfile openssl.cnf

这样,我们就得到如下的配置:

cafile /etc/mosquitto/ssl/ca-cert.pem
certfile /etc/mosquitto/ssl/server-cert.pem
keyfile /etc/mosquitto/ssl/server-key.pem

同样的,把 ca-cert.pem 拷贝到每个设备上。或者把 ca-cert.pem 放到 Web 服务器,在设备上用 wget/curl 下载。

三、和客户端(设备)相关的坑

  • 由于设备支持加密方式的问题,需要在服务器端配置特别的 ciphers,可以根据客户端 “openssl ciphers” 命令的输出,直接配置在 ciphers 后面。
  • tls_version 也是一个小坑,在我的环境里,我注释掉了 # tls_version tlsv1

经过以上配置后,在客户端(设备)上,我们就可以用如下的命令来 subscribe 消息了。

mosquitto_sub -v -u "username" -P "Password" -t 'topic' -h mqtt.yj777.cn -p 8883 --cafile ca-cert.pem

ThinkCMF 框架上以SMTP认证方式配置邮件发送功能

ThinkCMF 框架采用了 PHPMailer 插件来实现邮件的首发。

由于阿里云虚拟主机采用的专用网络,封住了25端口,导致通过操作系统发送邮件的途径被中断。

ThinkCMF 框架的”邮箱配置”功能可以让我们通过 SMTP 认证来实现 Web 应用收发邮件的功能。

如下截图:我们使用阿里云企业邮箱,连接万网的 SMTP 服务器,以 TLS 加密方式连接 587 端口,实现 SMTP 认证。

单行 json 文件的分行以及关键字查找

在实际操作中遇到需要在某些 json 文件中查找是否具有特定关键字时,由于 json 文件为单行文件,我们需要把逗号转换成回车,就可以方便的查找到指定的关键字。

可以用以下的单行命令来完成:

$ for f in *.tpl; do tr \, \\n <$f|grep keywords; done