内部网络GitLab审计:公开项目的安全风险与自动化检测

本文详细介绍了如何通过未授权访问内部网络中的GitLab公开项目,使用Nuclei、Gitleaks等工具自动化检测代码泄露,包含完整的Python/Go实现代码和修复建议,帮助企业发现和修复敏感信息泄露风险。

审计GitLab:内部网络中的公开GitLab项目

在内部渗透测试中,一个容易被忽视但充满宝藏的地方就是那些明目张胆隐藏的机密信息。即在内部网络的许多情况下不需要身份验证的地方。我指的是源代码管理平台,特别是GitLab。其他自托管平台可能也容易受到这种未授权技术的攻击,但我们将重点讨论GitLab。

我听过这样一句话: 高级开发人员先生说:“如果攻击者已经访问了我们的内部网络,那我们就有更大的问题了。” 最大的问题就是这种想法!是的,黑客进入你的内部网络是一个需要立即关注的问题,但作为组织,在这种事件发生之前实施的防御措施才是真正重要的。一个常见的误解是,一旦对手获得了组织网络的初始访问权限,游戏就已经结束了。事实上,我告诉你,好邻居,情况并非如此!你可以做很多事情来防御内部网络中的攻击者。例如,通过canary令牌和其他很棒的东西在每个角落设置安全地雷。但在这篇博客文章中,我们将讨论攻击和防御(但主要是攻击)自托管的GitLab实例。

我在组织的内部网络中无数次遇到内部GitLab实例,其中许多都有一个共同点:它们的许多项目都设置为“公开”。有人可能会想或脱口而出:“嗯,首先你需要一个有效的账户登录GitLab才能访问这些项目,”对此,我充满活力地说:“错误!”

在GitLab中,当项目范围设置为“公开”时,任何有网络访问权限的人仍然可以访问。更好的是,可以通过GitLab项目API在URL https://<gitlab.example.com>/api/v4/projects 中发现它。

剧透警告:找到所有公开的GitLab项目并下载所有内容的过程可以快速自动化,无需身份验证。在一个有趣的旁注轶事中,Nessus不会告诉你这一点,但你组织的皇冠上的明珠完全暴露了!因为这是一个功能,而不是一个错误。但这并不是说Nessus完全没有成果。Nessus仍然会为你识别所有GitLab实例,也就是说,如果你在使用Nessus的话。无论你是否使用Nessus,我们仍然可以轻松地使用Nuclei识别内部网络上的所有GitLab实例,更具体地说,是一个Nuclei GitLab工作流:

1
2
3
nuclei -l in-scope-cidrs-ips-hosts-urls-whatever.txt \
-w ~/nuclei-templates/workflows/gitlab-workflow.yaml \
-o gitlab-nuclei-workflow.log | tee gitlab-nuclei-workflow-color.log

下面的截图显示了上述命令的部分输出。 (截图:Nuclei GitLab工作流部分输出(经过编辑))

有许多代码秘密扫描工具,如Trufflehog、Gitleaks、NoseyParker等。在这篇博客文章中,我们将使用Gitleaks,但作为练习,我鼓励你使用所有三种工具并比较结果。在撰写本文时,许多这些工具的一个缺点是依赖于身份验证进行大规模自动化扫描,但这也可以从未经身份验证的上下文中完成(当GitLab公共仓库API可访问时)。如果你在内部渗透测试中遇到过GitLab实例,但不确定如何自动化并实现那种甜蜜多汁的入侵,那么这篇博客就是为你准备的。

正如Pastor Manul Laphroaig所说,PoC || GTFO!

掠夺GitLab

原谅我,邻居们,如果这个功能已经存在于任何给定的开源工具中,但请允许我讨论从头开始自动化这个过程。我们将使用Python和Go的杂牌团队。

克隆所有东西

这理想地应该作为一些这些工具的功能包含——或者也许已经包含了——尽管如此,这里有一个Python脚本,用于将每个公共仓库下载到它们适当命名的目录层次结构中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#!/usr/bin/env python3

import requests
import json
import subprocess
import os

PWD = os.getcwd()

def get_repos_with_auth(projects_url, base_url, token):
    headers = {'Private-Token': token}
    repos = {}
    page = 1

    while True:
        response = requests.get(projects_url, headers=headers, verify=False, params={'per_page': 100, 'page': page})
        data = json.loads(response.text)
        if not data:
            break
        for repo in data:
            print(f"Repo {repo['http_url_to_repo']}")
            path = repo['path_with_namespace']
            repos[path] = f"{base_url}{path}.git"
        page += 1

    return repos

def get_repos(projects_url, base_url):
    repos = {}
    page = 1

    while True:
        response = requests.get(projects_url, verify=False, params={'per_page': 100, 'page': page})
        data = json.loads(response.text)
        if not data:
            break
        for repo in data:
            print(f"Repo {repo['http_url_to_repo']}")
            path = repo['path_with_namespace']
            repos[path] = f"{base_url}{path}.git"
        page += 1

    return repos

def run_command(command):
    try:
        subprocess.call(command, shell=True)
    except:
        print("Error executing command")

def clone_repos(repos: dict):
    for path, repo in repos.items():
        dirs = path.split("/")

        directory = "/".join(dirs[:-1])
        if not os.path.exists(directory):
            os.makedirs(directory)

        if os.path.exists(f"{PWD}/{directory}/{os.path.basename(repo).rstrip('.git')}"):
            continue

        os.chdir(directory)
        clone_cmd = f"git clone {repo}"
        print(clone_cmd)
        run_command(clone_cmd)
        os.chdir(PWD)

def main():
    # token = "CHANGETHIS" # CHANGETHIS if using auth_base_url
    # user_id = "CHANGETHIS" # CHANGETHIS if using auth_base_url
    projects_url = "https://<GITLAB.DOMAIN.COM>/api/v4/projects" # CHANGETHIS
    # auth_base_url = f"https://{user_id}:{token}@<GITLAB.DOMAIN.COM>/" # CHANGETHIS.
    unauth_base_url = f"https://<GITLAB.DOMAIN.COM>/" # CHANGETHIS.
    # repos = get_repos_with_auth(projects_url, auth_base_url, token)
    repos = get_repos(projects_url, unauth_base_url)
    print(f"Total Repos: {len(repos)}")
    clone_repos(repos)


if __name__ == "__main__":
    main()

get_repos()函数中,我们每次分页遍历所有可用的仓库数据,每次100个项目,直到没有剩余数据。这个脚本可以(而且可能应该)接受参数或配置文件以便移植,但让我们沉浸在硬编码事物的氛围中,即凭据。使用更新的projects_urlunauth_base_url值未经身份验证运行上述代码看起来像这样: (截图:搜索和克隆所有可用仓库(经过编辑))

Gitleaks所有东西

接下来,我们将使用Gitleaks扫描所有内容。首先,让我们克隆项目,这样我们就有gitleaks.toml文件了,我们可以单独下载这个文件,但是,谁在乎呢。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
git clone https://github.com/gitleaks/gitleaks.git /opt/gitleaks
# 下载gitleaks二进制文件,这假设你已经安装了go并设置了GOPATH...
# 如果没有,这里是如何做到的。
# 安装go..
# 如果你使用Bash,在~/.zshrc中设置你的GOPATH,然后根据需要更改为~/.bash_profile或~/.bashrc

[[ ! -d "${HOME}/go" ]] && mkdir "${HOME}/go"
if [[ -z "${GOPATH}" ]]; then
cat << 'EOF' >> "${HOME}/.zshrc"

# 添加~/go/bin到路径
[[ ":$PATH:" != *":${HOME}/go/bin:"* ]] && export PATH="${PATH}:${HOME}/go/bin"
# 设置GOPATH
if [[ -z "${GOPATH}" ]]; then export GOPATH="${HOME}/go"; fi
EOF
fi

# 现在go已经安装,我们可以将gitleaks二进制文件安装到我们的PATH中
go install github.com/zricethezav/gitleaks/v8@latest

首先,我们将为额外的秘密添加一个额外的规则。这个规则容易产生误报,但当它捕获到否则会被遗漏的东西时,额外的噪音是值得的。将以下内容添加到你的/opt/gitleaks/config/gitleaks.toml文件中:

1
2
3
4
5
6
[[rules]]
id = "generic-password"
description = "Generic Password"
regex = '''(?i)password\s*[:=|>|<=|=>|:]\s*(?:'|"|\x60)([\w.-]+)(?:'|"|\x60)'''
tags = ["generic", "password"]
secretGroup = 1

要对单个仓库运行Gitleaks,你可以使用如下语法:

1
2
# cd进入一个克隆的仓库
gitleaks detect . -v -r output.json -c /opt/gitleaks/config/gitleaks.toml

但我们对大规模测试感兴趣,所以我们可以使用另一个一次性的Python脚本来做到这一点:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#!/usr/bin/env python3

import os
import subprocess

PWD = os.getcwd()
GITLEAKS_CONFIG_PATH = "/opt/gitleaks/config/gitleaks.toml" # 如果不使用/opt/gitleaks/config/gitleaks.toml,请更改此设置

def run_command(command):
    try:
        subprocess.call(command, shell=True)
    except:
        print("Error executing command")

def find_git_repos():
    repos = []
    for root, dirs, _ in os.walk('.'):
        if '.git' in dirs:
            git_dir = os.path.join(root, '.git')
            repo_dir = os.path.abspath(os.path.join(git_dir, '..'))
            repos.append(repo_dir)
    return repos

repo_dirs = find_git_repos()
for repo_dir in repo_dirs:
    repo_name = os.path.basename(repo_dir)
    if os.path.exists(f"/root/blog/loot/gitlab/{repo_name}.json"):  # 如果不使用/root/bhisblog/loot/gitlab,请更改此设置
        project_name = os.path.basename(os.path.dirname(repo_dir))
        repo_name = f"{project_name}_{repo_name}"
    os.chdir(repo_dir)
    cmd = f"gitleaks detect . -v -r /root/blog/loot/gitlab/{repo_name}.json -c {GITLEAKS_CONFIG_PATH}"  # 如果不使用/root/blog/loot/gitlab,请更改此设置
    print(cmd)
    run_command(cmd)
    os.chdir(PWD)

这个脚本将对每个仓库运行Gitleaks,并将结果的秘密写入JSON输出文件。这一切都很好,但我们可以做得更好一点(好得多的是将所有这些逻辑结合到一个单一的工具中,或者分叉并将这个功能实现到一个现有的工具中)。在这里,我们可以看到Gitleaks正在做它的事情。 (截图:Gitleaks部分输出(经过编辑))

合并所有东西

好吧,那么…现在,该怎么办???我该怎么处理所有这些JSON文件?让我们写另一个程序,这次用Go编写,将所有JSON输出文件合并成一个CSV文件。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
package main 

import ( 
    "encoding/csv" 
    "encoding/json" 
    "fmt" 
    "os" 
    "path/filepath" 
) 

type Item struct { 
    Description  string   `json:"Description"` 
    StartLine    int      `json:"StartLine"` 
    EndLine      int      `json:"EndLine"` 
    StartColumn  int      `json:"StartColumn"` 
    EndColumn    int      `json:"EndColumn"` 
    Match        string   `json:"Match"` 
    Secret       string   `json:"Secret"` 
    File         string   `json:"File"` 
    SymlinkFile  string   `json:"SymlinkFile"` 
    Commit       string   `json:"Commit"` 
    Entropy      float64  `json:"Entropy"` 
    Author       string   `json:"Author"` 
    Email        string   `json:"Email"` 
    Date         string   `json:"Date"` 
    Message      string   `json:"Message"` 
    Tags         []string `json:"Tags"` 
    RuleID       string   `json:"RuleID"` 
    Fingerprint  string   `json:"Fingerprint"` 
} 

func main() { 
    dirPath := "/root/work/loot/gitleaks" // 更改我 
    csvPath := "/root/work/loot/all_gitleaks.csv" // 更改我 

    items := make([]Item, 0) 

    err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { 
        if err != nil { 
            return err 
        } 
        if !info.IsDir() && filepath.Ext(path) == ".json" { 
            file, err := os.ReadFile(path) 
            if err != nil { 
                return err 
            } 

            var data []Item 
            err = json.Unmarshal(file, &data) 
            if err != nil { 
                fmt.Println(fmt.Errorf("error unmarshalling JSON file %s: %s", path, err)) 
            } 

            items = append(items, data...) 
        } 
        return nil 
    }) 
    if err != nil { 
        panic(err) 
    } 

    file, err := os.Create(csvPath) 
    if err != nil { 
        panic(err) 
    } 
    defer file.Close() 

    writer := csv.NewWriter(file) 
    defer writer.Flush() 

    headers := []string{"Description", "StartLine", "EndLine", "StartColumn", "EndColumn", "Match", "Secret", "File", "SymlinkFile", "Commit", "Entropy", "Author", "Email", "Date", "Message", "Tags", "RuleID", "Fingerprint"} 
    err = writer.Write(headers) 
    if err != nil { 
        panic(err) 
    } 

    for _, item := range items { 
        row := []string{ 
            item.Description, 
            fmt.Sprintf("%d", item.StartLine), 
            fmt.Sprintf("%d", item.EndLine), 
            fmt.Sprintf("%d", item.StartColumn), 
            fmt.Sprintf("%d", item.EndColumn), 
            item.Match, 
            item.Secret, 
            item.File, 
            item.SymlinkFile, 
            item.Commit, 
            fmt.Sprintf("%f", item.Entropy), 
            item.Author, 
            item.Email, 
            item.Date, 
            item.Message, 
            fmt.Sprintf("%v", item.Tags), 
            item.RuleID, 
            item.Fingerprint, 
        } 
        err = writer.Write(row) 
        if err != nil { 
            panic(err) 
        } 
    } 
}

运行go程序:

1
go run main.go 

再次强调,这些一次性的脚本和程序中的每一个都可以(而且应该)集成到像Trufflehog、Gitleaks、Noseyparker这样的工具中,或者合并成一个独立的脚本或工具。我将把这留给你,作为像好邻居一样为开源做贡献的练习。最初将每个步骤分解为单独的脚本是在没有凭据的情况下掠夺GitLab过程的最快原型方法,作为初始的概念验证。

分析所有东西

通过Excel或Libre Open Office将CSV文件导入为筛选表可以极大地帮助我们快速进行分析工作。 (截图:从CSV导入Excel数据)

按描述或日期筛选的能力将对我们大有裨益。 (截图:Microsoft Excel导入的CSV文件带有列筛选器(经过编辑))

如果你幸运地发现了一个启用的GitLab个人访问令牌,你可以用user_id和个人访问令牌更新第一个脚本,并再次运行脚本。

修复、缓解和预防

以下是你可以做的事情,以确保这种攻击不会发生在你的组织身上:

修复

  • 从源代码中删除所有敏感数据。
  • 删除仓库历史中包含秘密的先前提交。
  • 如果有太多违规提交,一旦敏感数据从源代码中删除,创建一个新的仓库并将新的清理后的代码提交到新仓库。

缓解

  • 将所有GitLab项目设置为私有,并根据需要授予访问权限。
  • 将GitLab项目设置中的“公开”视为开源的意思。如果你不希望项目公开访问,请将项目设置为私有。

预防

  • 使用TruffleHog、GitGuardian等工具实施代码扫描CI/CD流水线。
  • 使用TruffleHog、GitGuardian等工具实施预提交钩子。
  • 不要在公共或私有项目仓库中硬编码凭据或敏感信息。
  • 教育开发人员和DevOps工程师有关软件开发相关的安全最佳实践。

结束语

在你的下一次内部网络渗透测试中,注意那些有公开项目和API访问权限的GitLab实例!你可能会对你可能发现的东西感到惊讶😉我希望这篇博客文章激励你为开源做贡献并创建你自己的工具。我没有为此编写开源GitHub项目的部分原因是引起人们对这个过程每个单独步骤逻辑的关注。分叉现有工具并发出拉取请求也是如此。我还发现了这个工具https://github.com/punk-security/secret-magpie,旨在实现我们在这篇博客文章中讨论的内容,但再次,据我快速查看源代码所知,在撰写这篇博客时,它似乎不支持从未经身份验证的上下文中执行这种技术。

资源和参考

有关Trufflehog的更多信息,请参见:

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计