内部网络GitLab审计:公开项目的安全风险与自动化检测
审计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工作流:
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脚本,可以将每个公共仓库下载到它们适当命名的目录层次结构中:
#!/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_url
和unauth_base_url
值未经身份验证运行上述代码看起来像这样:
![搜索和克隆所有可用仓库(已编辑)]
Gitleaks所有东西
接下来,我们将使用Gitleaks扫描所有内容。首先,让我们克隆项目,这样我们就有了gitleaks.toml
文件,我们可以单独下载这个文件,但谁在乎呢。
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
文件中:
[[rules]]
id = "generic-password"
description = "Generic Password"
regex = '''(?i)password\s*[:=|>|<=|=>|:]\s*(?:'|"|\x60)([\w.-]+)(?:'|"|\x60)'''
tags = ["generic", "password"]
secretGroup = 1
要对单个仓库运行Gitleaks,你可以使用如下语法:
# cd进入一个克隆的仓库
gitleaks detect . -v -r output.json -c /opt/gitleaks/config/gitleaks.toml
但我们对大规模测试感兴趣,所以我们可以使用另一个一次性的Python脚本来做到这一点:
#!/usr/bin/env python3
import os
import subprocess
PWD = os.getcwd()
GITLEAKS_CONFIG_PATH = "/opt/gitleaks/config/gitleaks.toml" # CHANGETHIS if not using /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"): # CHANGETHIS if not using /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}" # CHANGEME if not using /root/blog/loot/gitlab
print(cmd)
run_command(cmd)
os.chdir(PWD)
这个脚本将对每个仓库运行Gitleaks,并将生成的密钥写入JSON输出文件。这都很好,但我们可以做得更好一点(好得多的是将所有这些逻辑合并到一个单一的工具中,或者分叉并将这个功能实现到现有的工具中)。在这里,我们可以看到Gitleaks正在做它的事情。
![Gitleaks部分输出(已编辑)]
合并所有东西
好的,那么...现在,我该怎么做这些JSON文件?让我们写另一个程序,这次用Go写,将所有JSON输出文件合并成一个CSV文件。
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" // CHANGE ME
csvPath := "/root/work/loot/all_gitleaks.csv" // CHANGE ME
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程序:
go run main.go
再次强调,这些一次性的脚本和程序可以(而且应该)集成到诸如Trufflehog、Gitleaks、Noseyparker之类的工具中,或者合并成一个独立的脚本或工具。我将把这留给你作为练习,像一个好邻居一样为开源做贡献。最初将每个步骤分解为单独的脚本是在没有凭据的情况下掠夺GitLab过程的最快原型方法,作为初始的概念验证。
分析所有东西
通过Excel或Libre Open Office将CSV文件导入为筛选表可以极大地帮助我们快速进行分析工作。
![Excel数据从CSV导入]
按描述或日期筛选的能力将对我们大有裨益。
![Microsoft Excel导入的CSV文件带有列筛选器(已编辑)]
如果你幸运地发现了一个启用的GitLab个人访问令牌,你可以用user_id
和个人访问令牌更新第一个脚本,并再次运行该脚本。
修复、缓解和预防
以下是你可以做的,以确保这种攻击不会发生在你的组织身上:
修复
- 从源代码中删除所有敏感数据。
- 删除仓库历史中包含密钥的先前提交。
- 如果有太多违规提交,一旦从源代码中删除了敏感数据,创建一个新的仓库并将新的清理后的代码提交到新仓库。
缓解
- 将所有GitLab项目设置为私有,并根据需要授予访问权限。
- 将GitLab项目设置中的“公开”视为开源的意思。如果你不希望项目公开访问,请将项目设置为私有。
预防
- 使用TruffleHog、GitGuardian或其他工具实施代码扫描CI/CD流水线。
- 使用TruffleHog、GitGuardian或其他工具实施预提交钩子。
- 不要在公共或私有项目仓库中硬编码凭据或敏感信息。
- 教育开发人员和DevOps工程师有关软件开发相关的安全最佳实践。
结束语
在你的下一次内部网络渗透测试中,注意那些具有公开项目和API访问权限的GitLab实例!你可能会对你可能发现的东西感到惊讶:winking_face:我希望这篇博客文章激励你为开源做贡献并创建自己的工具。我没有为此编写开源GitHub项目的部分原因是为了引起对这个过程每个单独步骤逻辑的关注。分叉现有工具并发出拉取请求也是如此。我还发现了这个工具https://github.com/punk-security/secret-magpie,它旨在实现我们在本文中讨论的内容,但再次,据我快速查看源代码所知,在撰写本文时,它似乎不支持从未经身份验证的上下文中执行此技术。
资源和参考
- https://github.com/gitleaks/gitleaks
- https://docs.gitlab.com/ee/user/public_access.html
- https://docs.gitlab.com/ee/api/projects.html
- https://github.com/projectdiscovery/nuclei
- https://github.com/projectdiscovery/nuclei-templates/blob/main/workflows/gitlab-workflow.yaml
- https://github.com/praetorian-inc/noseyparker
- https://github.com/punk-security/secret-magpie
- https://pre-commit.com/
- https://github.com/GitGuardian/ggshield-action
有关Trufflehog的更多信息,请参见:
- https://github.com/trufflesecurity/trufflehog
- https://www.blackhillsinfosec.com/rooting-for-secrets-with-trufflehog/
更多精彩内容 请关注我的个人公众号 公众号(办公AI智能小助手)
公众号二维码