Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
887713d
decouple manx from core
uruwhy Jan 8, 2026
5ca37cd
iteration fallback
uruwhy Jan 8, 2026
4e6f53a
fix unit test
uruwhy Jan 8, 2026
75c8a4e
style fix
uruwhy Jan 8, 2026
f9fdb1a
Merge branch 'master' into VIRTS-5033
uruwhy Jan 9, 2026
071ad80
Updated test for new Contact class
jlklos Jan 9, 2026
a861d61
Updated test for new Contact class
jlklos Jan 13, 2026
8b912ce
Updated test for new Contact class
jlklos Jan 13, 2026
d83b25e
Updated test for new Contact class
jlklos Jan 13, 2026
67fa739
Updated new Contact class to handle services=None from test_contact_t…
jlklos Jan 14, 2026
0d9cc27
Updated new Contact class to handle services=None from test_contact_t…
jlklos Jan 14, 2026
d196b28
Updated TestContact class to address test issues
jlklos Jan 14, 2026
79e1ef9
Updated TestContact class to address test issues
jlklos Jan 14, 2026
14b85be
Updated TestContact class to address test issues
jlklos Jan 14, 2026
853578b
Updated TestContact class to address test issues
jlklos Jan 14, 2026
c440be1
Updated TestContact class to address test issues
jlklos Jan 14, 2026
6999dc0
Updated TestContact class to address test issues
jlklos Jan 16, 2026
c3f610b
Updated TestContact class to address test issues
jlklos Jan 16, 2026
ea59f65
Fixed newline issue at end of file
jlklos Jan 16, 2026
1831020
Added to test_contact_tcp.py
jlklos Jan 20, 2026
e293d51
Initial patching for TestContact added to test_contact_tcp.py
jlklos Jan 21, 2026
e1005bd
Merge branch 'master' into VIRTS-5033
jlklos Jan 21, 2026
2a84207
Corrected leftover artifacts in test_contact_tcp.py
jlklos Jan 21, 2026
f858188
Added patchers to test_contact_tcp.py
jlklos Jan 21, 2026
60469a9
Merge branch 'master' into VIRTS-5033
uruwhy Jan 30, 2026
cd22dc3
Added test for _attempt_connection function
jlklos Jan 31, 2026
d0b5a45
Removed blank lines for flake8 compliance
jlklos Jan 31, 2026
e0cba5c
Added blank lines for flake8 compliance
jlklos Jan 31, 2026
2234641
Added test for handle_sessions function within Contact class
jlklos Feb 2, 2026
ea5862c
Added tests for accept and send functions within TcpSessionHandler class
jlklos Feb 2, 2026
3a9e433
Added test for send function (invalid session id) within TcpSessionHa…
jlklos Feb 4, 2026
65d38aa
Added tests for send function (session errors) within TcpSessionHandl…
jlklos Feb 4, 2026
d2cdd55
update tcp tests
uruwhy Feb 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 28 additions & 20 deletions app/contacts/contact_tcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
from typing import Tuple

from app.utility.base_world import BaseWorld
from plugins.manx.app.c_session import Session
from app.contacts.utility.c_tcp_session import TCPSession


class Contact(BaseWorld):

def __init__(self, services):
self.name = 'tcp'
self.description = 'Accept beacons through a raw TCP socket'
self.services = services
self.log = self.create_logger('contact_tcp')
self.contact_svc = services.get('contact_svc')
self.tcp_handler = TcpSessionHandler(services, self.log)
Expand Down Expand Up @@ -93,18 +94,14 @@ def __init__(self, services, log):
self.sessions = []

async def refresh(self):
index = 0

while index < len(self.sessions):
session = self.sessions[index]

refreshed_sessions = []
for session in self.sessions:
try:
session.writer.write(str.encode(' '))
session.write_bytes(str.encode(' '))
refreshed_sessions.append(session)
except socket.error:
self.log.debug('Error occurred when refreshing session %s. Removing from session pool.', session.id)
del self.sessions[index]
else:
index += 1
self.sessions = refreshed_sessions

async def accept(self, reader, writer):
self.log.debug('Accepting connection.')
Expand All @@ -116,19 +113,30 @@ async def accept(self, reader, writer):
profile['executors'] = [e for e in profile['executors'].split(',') if e]
profile['contact'] = 'tcp'
agent, _ = await self.services.get('contact_svc').handle_heartbeat(**profile)
new_session = Session(id=self.generate_number(size=6), paw=agent.paw, reader=reader, writer=writer)
new_session = TCPSession(id=self.generate_number(size=6), paw=agent.paw, reader=reader, writer=writer)
self.sessions.append(new_session)
await self.send(new_session.id, agent.paw, timeout=5)

async def send(self, session_id: int, cmd: str, timeout: int = 60) -> Tuple[int, str, str, str]:
try:
session = next(i for i in self.sessions if i.id == int(session_id))
session.writer.write(str.encode(' '))
try:
session = next(i for i in self.sessions if i.id == int(session_id))
except StopIteration:
msg = f'Could not find session with ID {session_id}'
self.log.error(msg)
return 1, '~$ ', msg, ''

session.write_bytes(str.encode(' '))
time.sleep(0.01)
session.writer.write(str.encode('%s\n' % cmd))
response = await self._attempt_connection(session_id, session.reader, timeout=timeout)
response = json.loads(response)
return response['status'], response['pwd'], response['response'], response.get('agent_reported_time', '')
session.write_bytes(str.encode('%s\n' % cmd))
response = await self._attempt_connection(session, timeout=timeout)
if response:
response = json.loads(response)
return response.get('status', 1), response.get('pwd', '~$ '), response.get('response', 'No response provided'), response.get('agent_reported_time', '')
else:
msg = f'Failed to read data from session {session.id}'
self.log.error(msg)
return 1, '~$ ', msg, ''
except Exception as e:
self.log.exception(e)
return 1, '~$ ', str(e), ''
Expand All @@ -138,17 +146,17 @@ async def _handshake(reader):
profile_bites = (await reader.readline()).strip()
return json.loads(profile_bites)

async def _attempt_connection(self, session_id, reader, timeout):
async def _attempt_connection(self, session, timeout):
buffer = 4096
data = b''
time.sleep(0.1) # initial wait for fast operations.
while True:
try:
part = await reader.read(buffer)
part = await session.read_bytes(buffer)
data += part
if len(part) < buffer:
break
except Exception as err:
self.log.error("Timeout reached for session %s", session_id)
self.log.error("Timeout reached for session %s", session.id)
return json.dumps(dict(status=1, pwd='~$ ', response=str(err)))
return str(data, 'utf-8')
32 changes: 32 additions & 0 deletions app/contacts/utility/c_tcp_session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from app.utility.base_object import BaseObject


class TCPSession(BaseObject):

@property
def unique(self):
return self.hash('%s' % self.paw)

def __init__(self, id, paw, reader, writer):
super().__init__()
self.id = id
self.paw = paw
self._reader = reader
self._writer = writer

def store(self, ram):
existing = self.retrieve(ram['sessions'], self.unique)
if not existing:
ram['sessions'].append(self)
return self.retrieve(ram['sessions'], self.unique)
return existing

def write_bytes(self, input):
"""Wrapper for self._writer.write"""

return self._writer.write(input)

def read_bytes(self, buffer):
"""Wrapper for self._reader.read"""

return self._reader.read(buffer)
105 changes: 104 additions & 1 deletion tests/contacts/test_contact_tcp.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,43 @@
import logging
import socket
from unittest import mock
import pytest


from app.service.contact_svc import ContactService
from app.utility.base_world import BaseWorld
from app.contacts.contact_tcp import TcpSessionHandler
from app.contacts.contact_tcp import Contact
from app.contacts.utility.c_tcp_session import TCPSession
from app.objects.secondclass.c_instruction import Instruction

logger = logging.getLogger(__name__)


@pytest.fixture
def tcp_c2(app_svc, contact_svc, data_svc, obfuscator):
services = app_svc.get_services()
tcp_contact_svc = Contact(services=services)
return tcp_contact_svc


class _MockReader:
async def read(self, n=-1):
return b'MockContent'


class _MockWriter:
def write(self, data):
pass


class TestTcpSessionHandler:

def test_refresh_with_socket_errors(self, event_loop):
handler = TcpSessionHandler(services=None, log=logger)

session_with_socket_error = mock.Mock()
session_with_socket_error.writer.write.side_effect = socket.error()
session_with_socket_error.write_bytes.side_effect = socket.error()

handler.sessions = [
session_with_socket_error,
Expand All @@ -35,3 +59,82 @@ def test_refresh_without_socket_errors(self, event_loop):

event_loop.run_until_complete(handler.refresh())
assert len(handler.sessions) == 3

async def test_attempt_connection(self, tcp_c2):
MockSession = TCPSession(id=123456, paw='testpaw', reader=_MockReader(), writer=_MockWriter())
assert "MockContent" == await tcp_c2.tcp_handler._attempt_connection(MockSession, timeout=1)

async def test_accept(self, tcp_c2):
dummy_profile = {
'architecture': 'amd64',
'exe_name': 'splunkd',
'executors': 'sh',
'host': 'Caldera',
'location': './splunkd',
'pid': 10057,
'platform': 'linux',
'ppid': 9752,
'server': '0.0.0.0:7010',
'username': 'caldera'
}
with mock.patch.object(TcpSessionHandler, '_handshake', return_value=(dummy_profile)):
await tcp_c2.tcp_handler.accept(reader=_MockReader(), writer=_MockWriter())
assert len(tcp_c2.tcp_handler.sessions) == 1

async def test_accept_err(self, tcp_c2):
with mock.patch.object(TcpSessionHandler, '_handshake', side_effect=Exception('mock exception')):
await tcp_c2.tcp_handler.accept(reader=_MockReader(), writer=_MockWriter())
assert len(tcp_c2.tcp_handler.sessions) == 0

async def test_send_no_session(self, tcp_c2):
status, pwd, response, agent_time = await tcp_c2.tcp_handler.send(session_id=999999, cmd='whoami', timeout=1)
assert status == 1
assert 'Could not find session with ID 999999' == response
assert pwd == '~$ '
assert agent_time == ''

async def test_send_with_session_err(self, tcp_c2):
mock_session = TCPSession(id=123456, paw='testpaw', reader=_MockReader(), writer=_MockWriter())
tcp_c2.tcp_handler.sessions.append(mock_session)
with mock.patch.object(TcpSessionHandler, '_attempt_connection', side_effect=Exception('Test exception')):
status, pwd, response, agent_time = await tcp_c2.tcp_handler.send(session_id=123456, cmd='whoami', timeout=1)
assert status == 1
assert 'Test exception' == response
assert pwd == '~$ '
assert agent_time == ''

async def test_send_with_session_no_response(self, tcp_c2):
mock_session = TCPSession(id=123456, paw='testpaw', reader=_MockReader(), writer=_MockWriter())
tcp_c2.tcp_handler.sessions.append(mock_session)
with mock.patch.object(TcpSessionHandler, '_attempt_connection', return_value=''):
status, pwd, response, agent_time = await tcp_c2.tcp_handler.send(session_id=123456, cmd='whoami', timeout=1)
assert status == 1
assert 'Failed to read data from session 123456' == response
assert pwd == '~$ '
assert agent_time == ''


class TestContact:
def test_tcp_contact(self, event_loop, tcp_c2):
BaseWorld.set_config('main', 'app.contact.tcp', '127.0.0.1:57012')
dummy_instruction = Instruction(
id='123',
sleep=5,
command='whoami',
executor='sh',
timeout=60,
payloads=[],
uploads=[],
deadman=False,
delete_payload=True
)
tcp_c2.tcp_handler.sessions.append(TCPSession(
id=1,
paw='dummy_paw',
reader=_MockReader(),
writer=_MockWriter()
))
event_loop.run_until_complete(tcp_c2.start())
with mock.patch.object(ContactService, 'handle_heartbeat', return_value=('dummy_paw', [dummy_instruction])):
event_loop.run_until_complete(tcp_c2.handle_sessions())
assert len(tcp_c2.tcp_handler.sessions) == 1
Loading