跳到主要内容

代码审计:SyntaxFlow 规则编写之 Golang 实战

· 阅读需 8 分钟
Yak ProjectYak Project

在上一篇文章中介绍了 Syntaxflow 的基础用法以及在 Java 中的实战应用,本篇文章主要用于丰富一些在编写规则上的实战细节

前文指路⬇️Yak,公众号:Yak ProjectSyntaxFlow Java实战(一):值的搜索与筛选

为了更好的理解 Syntaxflow 规则的语法,可以先看如下这个例子:

package main
import (
"database/sql"
"fmt"
"log"
"net/http"
_ "github.com/go-sql-driver/mysql"
)
funclogin(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
// 不安全的 SQL 查询
query := fmt.Sprintf("SELECT * FROM users WHERE username='%s' AND password='%s'", username, password)
db, err := sql.Open("mysql", "user:password@/dbname")
if err != nil {
log.Fatal(err)
}
defer db.Close()
var userID int
err = db.QueryRow(query).Scan(&userID)
if err != nil {
http.Error(w, "Invalid login", http.StatusUnauthorized)
return
}
fmt.Fprintf(w, "User ID: %d", userID)
}
funcmain() {
http.HandleFunc("/login", login)
log.Fatal(http.ListenAndServe(":8080", nil))
}

这是一个有安全分析的例子:使用 fmt.Sprintf 来拼接 SQL 被视为不安全的代码行为

那么我们该如何通过编写 Syntaxflow 规则来扫描代码中的安全问题呢?

查找以及定位代码位置

基础搜索

Syntaxflow 的搜索遵循 Use-Def 链,有两个核心的查找功能:

  • #->:追踪符号的定义
  • -->:追踪符号的使用

例如使用如下规则来查找QueryRow函数的传入参数:QueryRow(* #-> as $target)

可以发现已经能够查找到问题代码了。但仅仅是查找还不够,还需要添加格外的限制条件来定位问题代码的位置

添加条件

例如,我们需要确定 db.QueryRow 中的 db 是否来自于SQL数据库。此时就可以通过定位 sql.Open() 的返回值来确定 db 是否符合条件,规则如下:

sql.Open <getCall> <getMembers> as $db;
$db.QueryRow(* #-> as $target);

在实际代码中我们会使用各种各样库,查找不同库中执行SQL语句的函数可能需要使用不同的Syntaxflow规则,如果用一个笼统的sql.Open来查找Open就可能出现同名误报的问题。

在这个案例中,我们使用 "database/sql" 库来执行 SQL 语句,那我们怎样确定sql.Open中的sql是否来自于"database/sql" 库呢?

Syntaxflow中提供了一个名为<fullTypeName>的结构,该结构用于展示一个符号的全称(即import导入的名称),配合过滤器?{...}就可以过滤出<fullTypeName>为特定值的符号,具体规则如下:

sql?{<fullTypeName>?{have: 'database/sql'}} as $entry;$entry.Open <getCall> <getMembers> as $db;$db.QueryRow(* #-> as $target);

当然在实际编写规则的过程中,不需要每次都通过上述规则来定位符号。我们已经在yakit的内置规则库中准备了各种常见库的定位规则,需要使用<include('...')>语法导入即可。

<include('golang-database-sql')> as $sink;$sink.QueryRow( * #-> as $target);

在规则管理界面查询 “info”,即可查看所有能够include导入的内置规则:

当然这个规则还可以进一步进行限制,例如我们限制查询QueryRow的第一个参数,就可以这样写:

<include('golang-database-sql')> as $sink;$sink.QueryRow(* #-> as $target,);

另外还有更加规范的写法:

<include('golang-database-sql')> as $sink;
$sink.QueryRow <getActualParams><slice(index=0)> #-> as $target;
  • nativeCall <getActualParams> 用于获取所有实参
  • 还有一个同类 nativeCall <getFormalParams> 用于获取所有形参

在审计具体的代码的时候,如果你想让你的 SF 规则可以审计通用代码,尽量不要具体指明参数名,此时可以通过目标函数的一些特征来获取函数形参:

funclistTables(db *sql.DB) {
rows, err := db.Query("SELECT * FROM users WHERE name='" + input + "'")
if err != nil {
fmt.Println("Failed to list tables:", err)
return
}
defer rows.Close()
for rows.Next() {
var tableName string
if err := rows.Scan(&tableName); err != nil {
fmt.Println("Failed to scan table name:", err)
return
}
fmt.Println("Table:", tableName)
}
}
.Query<getFunc><getFormalParams> as $param;
$param.QueryRow(* #-> as $target,);

跨过程的搜索

如果遇到跨过程的代码,往往我们不能直接通过函数名搜索形参:

func handler(w http.ResponseWriter, r *http.Request) {
......
}
func main() {
http.HandleFunc("/", handler)
http.ListenAndServe(":8080", nil)
}

在这个案例中,函数名“handler”是不固定的,假设其内部也未知(我们不能通过搜索特征后<getFunc>的方式获取形参)。如果此时我们仍需要分析该路由处理函数是否有安全问题,就可以通过其调用者间接搜索:

http?{<fullTypeName>?{have: 'net/http'}} as $entry;
$entry.HandleFunc(, * as $handle);
$handle <getFormalParams> as $output;

目前Syntaxflow内置规则已经集成了常见的HTTP库,调用golang-http-sink便可一键导入:

<include('golang-http-sink')> as $output;

匹配问题代码的行为

现在我们已经成功定位到目标代码的位置了,那么怎样确定该代码是否有安全问题呢?

其实这里Syntaxflow提供的规则非常灵活,以人的视角来看,我们认为随意对SQL语句进行拼接是不安全的行为。

判断是否有安全漏洞

对于本案例来说我们就可以直接在过滤器中判断db.QueryRow(...)调用参数的上层引用中是否出现fmt.Sprintf符号

<include('golang-database-sql')> as $sink;
$sink.QueryRow( * #-> as $target);
$target?{have: 'fmt.Sprintf'} as $unsafe;

当然也可以使用集合操作,在Syntaxflow中提供了如下几种集合操作:交集:&差集:-并集:+

交集运算符& 用于找出两个集合中共有的元素。在代码分析的上下文中,这可以用来确定两个不同数据流路径中共同的数据点或函数调用。

  • 交集运算符& 用于找出两个集合中共有的元素。在代码分析的上下文中,这可以用来确定两个不同数据流路径中共同的数据点或函数调用。
  • 差集运算符-用于从一个集合中移除存在于另一个集合中的元素。这在需要排除特定数据点或排除已处理(例如已经过滤或验证)的数据点时非常有用。
  • 并集运算符 +用于合并两个集合的元素,结果集包含两个集合中的所有元素(重复的元素只保留一份)。这在需要组合来自不同源的数据点时特别有用。

查看fmt.Sprintfdb.QueryRow参数的上层引用中是否出现交集:

<include('golang-database-sql')> as $sink;$sink.QueryRow( * #-> as $target);fmt.Sprintf as $check$target & $check as $unsafe

排除过滤函数

在实战中,单一依靠Syntaxflow查找规则来定位问题代码往往会出现许多误报,案例如下:

package main
import (
"fmt"
"io/ioutil"
"net/http"
)
const allowedBasePath = "/allowed/path/"
funchandler(w http.ResponseWriter, r *http.Request) {
userInput := r.URL.Query().Get("file")
content, err := ioutil.ReadFile(allowedBasePath + userInput)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
w.Write(content)
}
funcmain() {
http.HandleFunc("/", handler)
fmt.Println("Server is running on :8080")
http.ListenAndServe(":8080", nil)
}

这个案例有明显的未过滤文件或路径访问漏洞,使用Syntaxflow很容易就能定位:

ioutil?{<fullTypeName>?{have: 'io/ioutil'}} as $entry
$entry.ReadFile(* #-> as $target)

查询的结果可以看做是两点之间的数据流,但在这数据流之上可能有一些过滤函数来确保传入数据的安全性,例如:

  1. 使用filepath.Clean清洗路径,去除多余的..和.。

  2. 使用strings.HasPrefixfilepath.IsAbs验证路径是否在允许的基础路径下。

此时我们需要在搜索中排除数据流上的过滤函数,可以做出如下的优化:

ioutil?{<fullTypeName>?{have: 'io/ioutil'}} as $entry
filepath?{<fullTypeName>?{have: 'path/filepath'}} as $path
$entry.ReadFile(* #-> as $target)
strings.HasPrefix(* #-> as $check);
$path.Clean(* #-> as $check);
$path.IsAbs(* #-> as $check);
$path.Join(* #-> as $check);
$target #{include: `$check`}-> as $safe;
$target #{exclude: `$safe`}-> as $low;

为了方便控制数据流Syntaxflow封装成了一个叫 <dataflow>的NativeCall,用户可以调用这个指令,把数据流的所有路径整理成在一起,一起进行检查,直到过滤出自己想要的数据,具体语法如下:

ioutil?{<fullTypeName>?{have: 'io/ioutil'}} as $entry
$entry.ReadFile(* #-> as $target)
$target<dataflow(<<<CODE
*?{opcode: call && <name>?{have: filepath }} as $__next__
CODE)> as $check;
$target #{include: `$check`}-> as $safe;
$target #{exclude: `$safe`}-> as $low;

<dataflow>中可以内嵌其他的Syntaxflow规则,并且在$target查询结果的内部进行二次查询。

未来的计划

对golang而言,跨过程分析是未来发展的目标。现阶段yaklang引擎对于指针的处理有限,也没法精心处理interface,导致数据流在遇到interface时可能会断掉。这也是Syntaxflow在扫描大型golang项目时很难出结果的原因之一。

目前我们已经在尝试对interface结构进行补充,旨在更好地分析interface和structinterface和interface之间的关系


本文首发于 Yak Project 公众号,阅读原文