Skip to content

Commit af5b541

Browse files
authored
Merge pull request #27 from Wenzel/checksec_stats_seaborn
Checksec stats
2 parents 319b357 + 45cbbde commit af5b541

File tree

8 files changed

+240
-55
lines changed

8 files changed

+240
-55
lines changed

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,27 @@ available on `pip`.
9696
Follow the instructions in the `db` directory to run a it inside a docker
9797
container.
9898

99+
## VM setup
100+
101+
OSWatcher works on VMs stored in `libvirt`, either via `qemu:///session`
102+
or `qemu:///system`.
103+
104+
Note: `qemu:///session` is recommended as it requires less permission
105+
and should work without further configuration.
106+
107+
The only setup required is to specify a `release_date` in `JSON` format, so that
108+
the capture tool can insert this information in the database as well.
109+
110+
-> In the VM XML `<description>` field, add the following content:
111+
~~~JSON
112+
{"release_date": "2012-04-01"}
113+
~~~
114+
115+
You can use edit `virsh edit <domain>` or `virt-manager` tool which should be easier.
116+
99117
## Usage
100118

101-
The VM name will be searched via `Libvirt`.
119+
Start the capture tool on a `VM` and specify the hooks configuration.
102120

103121
~~~
104122
(venv) $ python -m oswatcher <vm_name> hooks.json

apps/checksec_stats.py

Lines changed: 80 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,38 @@
11
#!/usr/bin/env python3
22

33
"""
4-
Usage: script.py [options] <os>
4+
Usage:
5+
script.py --list
6+
script.py [options] <os_regex>
57
68
Options:
79
-h --help Display this message
10+
-l --list List available OS in the database
811
-d --debug Enable debug output
912
"""
1013

1114

1215
import sys
1316
import logging
17+
from datetime import datetime
1418
from collections import Counter
1519

20+
import matplotlib
21+
import matplotlib.pyplot as plt
22+
import seaborn as sns
23+
import pandas as pd
1624
from docopt import docopt
1725
from py2neo import Graph
1826

1927
from oswatcher.model import OS
2028

2129
DB_PASSWORD = "admin"
22-
30+
PROTECTIONS = ['relro', 'canary', 'nx', 'fortify_source', 'rpath', 'runpath', 'symtables']
31+
OS_CHECKSEC_QUERY = """
32+
MATCH (os:OS)-[:OWNS_FILESYSTEM]->(root:Inode)-[:HAS_CHILD*]->(i:Inode)
33+
WHERE os.name = '{}' AND i.checksec = True
34+
RETURN i
35+
"""
2336

2437
def init_logger(debug=False):
2538
logging_level = logging.INFO
@@ -37,41 +50,75 @@ def main(args):
3750
logging.info('connect to Neo4j DB')
3851
graph = Graph(password=DB_PASSWORD)
3952

40-
os_name = args['<os>']
41-
os = OS.match(graph).where("_.name = '{}'".format(os_name)).first()
42-
if os is None:
43-
logging.info('unable to find OS %s in the database', os_name)
53+
# list ?
54+
if args['--list']:
4455
logging.info('available operating systems:')
4556
for os in OS.match(graph):
46-
logging.info('⭢ %s', os.name)
57+
logging.info('\t%s', os.name)
58+
return
59+
60+
os_regex = args['<os_regex>']
61+
os_match = OS.match(graph).where("_.name =~ '{}'".format(os_regex))
62+
if os_match is None:
63+
logging.info('unable to find OS that matches \'%s\' regex in the database', os_regex)
4764
return 1
4865

49-
# TODO translate to py2neo API
50-
checksec_inodes = graph.run("MATCH (os:OS)-[:OWNS_FILESYSTEM]->(root:Inode)-[:HAS_CHILD*]->(i:Inode) WHERE os.name = 'ubuntu16.04' AND i.checksec = True return i")
51-
c = Counter()
52-
for node in checksec_inodes:
53-
inode = node['i']
54-
logging.debug('%s: %s', inode['name'], inode['mime_type'])
55-
c['total'] += 1
56-
if inode['relro']:
57-
c['relro'] += 1
58-
if inode['canary']:
59-
c['canary'] += 1
60-
if inode['nx']:
61-
c['nx'] += 1
62-
if inode['rpath']:
63-
c['rpath'] += 1
64-
if inode['runpath']:
65-
c['runpath'] += 1
66-
if inode['symtables']:
67-
c['symtables'] += 1
68-
if inode['fortify_source']:
69-
c['fortify_source'] += 1
70-
71-
logging.info('Results for %s', os.name)
72-
logging.info('Total binaries: %d', c['total'])
73-
for feature in ['relro', 'canary', 'nx', 'rpath', 'runpath', 'symtables', 'fortify_source']:
74-
logging.info('%s: %.1f%%', feature, c[feature] * 100 / c['total'])
66+
os_df_list = []
67+
# iterate over OS list, sorted by release date, converted from string to date object
68+
for os in sorted(os_match, key=lambda x: datetime.strptime(x.release_date, '%Y-%m-%d')):
69+
# TODO translate to py2neo API
70+
checksec_inodes = graph.run(OS_CHECKSEC_QUERY.format(os.name))
71+
c = Counter()
72+
for node in checksec_inodes:
73+
inode = node['i']
74+
logging.debug('%s: %s', inode['name'], inode['mime_type'])
75+
c['total'] += 1
76+
if inode['relro']:
77+
c['relro'] += 1
78+
if inode['canary']:
79+
c['canary'] += 1
80+
if inode['nx']:
81+
c['nx'] += 1
82+
if inode['rpath']:
83+
c['rpath'] += 1
84+
if inode['runpath']:
85+
c['runpath'] += 1
86+
if inode['symtables']:
87+
c['symtables'] += 1
88+
if inode['fortify_source']:
89+
c['fortify_source'] += 1
90+
91+
logging.info('Results for %s', os.name)
92+
logging.info('Total binaries: %d', c['total'])
93+
for feature in PROTECTIONS:
94+
logging.info('%s: %.1f%%', feature, c[feature] * 100 / c['total'])
95+
96+
# fix matplotlib, uses agg by default, non-gui backend
97+
matplotlib.use('tkagg')
98+
sns.set_style('whitegrid')
99+
100+
per_data = []
101+
for feature in PROTECTIONS:
102+
value = c[feature] * 100 / c['total']
103+
per_data.append(value)
104+
# initialize OS Panda DataFrame
105+
df = pd.DataFrame({'Protections': PROTECTIONS, 'Percentage': per_data, 'OS': os.name})
106+
os_df_list.append(df)
107+
108+
# concatenate all the individual DataFrames
109+
main_df = pd.concat(os_df_list, ignore_index=True)
110+
111+
logging.info('Displaying results...')
112+
if len(os_df_list) == 1:
113+
ax = sns.barplot(x="Protections", y="Percentage", data=main_df)
114+
ax.set_title('{} binary security overview'.format(os_regex))
115+
else:
116+
ax = sns.barplot(x="Protections", y="Percentage", hue="OS", data=main_df)
117+
ax.set_title('binary security overview for regex "{}"'.format(os_regex))
118+
# show plot
119+
plt.legend(loc='upper right')
120+
plt.show()
121+
75122

76123
if __name__ == '__main__':
77124
args = docopt(__doc__)

hooks/system.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# sys
22
import logging
3+
import json
4+
from datetime import datetime
5+
import xml.etree.ElementTree as ET
36

47
# local
58
from oswatcher.model import OS
@@ -23,7 +26,18 @@ def __init__(self, parameters):
2326
self.context.subscribe('protocol_end', self.insert_operating_system)
2427

2528
def build_operating_system(self, event):
26-
self.os = OS(self.domain_name)
29+
xml = self.context.domain.XMLDesc()
30+
root = ET.fromstring(xml)
31+
# find description
32+
elems = root.findall('./description')
33+
if not elems:
34+
raise RuntimeError('could not find description in XML, it contains the metadata !')
35+
desc = elems[0]
36+
try:
37+
metadata = json.loads(desc.text)
38+
except json.JSONDecodeError:
39+
raise RuntimeError('Could not load JSON metadata')
40+
self.os = OS(self.domain_name, metadata['release_date'])
2741

2842
def add_filesystem(self, event):
2943
logging.info('Adding root filesystem to OS node')

oswatcher/capture.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from tempfile import NamedTemporaryFile, TemporaryDirectory, gettempdir
1010

1111
# local
12+
from oswatcher.model import OS
1213
from oswatcher.utils import get_hard_disk
1314

1415
# 3rd
@@ -21,6 +22,12 @@
2122
__SCRIPT_DIR = os.path.dirname(os.path.realpath(sys.argv[0]))
2223
DB_PASSWORD = "admin"
2324
DESKTOP_READY_DELAY = 180
25+
SUBGRAPH_DELETE_OS = """
26+
MATCH (o:OS)-[*0..]-(x)
27+
WHERE o.name = "{}"
28+
WITH DISTINCT x
29+
DETACH DELETE x
30+
"""
2431

2532

2633
class QEMUDomainContextFactory(QEMUContextFactory):
@@ -85,10 +92,11 @@ def protocol(environement):
8592

8693

8794
def init_logger(debug=False):
95+
formatter = "%(asctime)s;%(levelname)s;%(message)s"
8896
logging_level = logging.INFO
8997
if debug:
9098
logging_level = logging.DEBUG
91-
logging.basicConfig(level=logging_level)
99+
logging.basicConfig(level=logging_level, format=formatter)
92100
# suppress annoying log output
93101
logging.getLogger("httpstream").setLevel(logging.WARNING)
94102
logging.getLogger("neo4j.bolt").setLevel(logging.WARNING)
@@ -105,7 +113,7 @@ def main(args):
105113
hooks_config = {}
106114
with open(hooks_config_path) as f:
107115
hooks_config = json.load(f)
108-
logging.info('connect to Neo4j DB')
116+
logging.info('Connect to Neo4j DB')
109117
graph = Graph(password=DB_PASSWORD)
110118

111119
if 'configuration' not in hooks_config:
@@ -123,12 +131,36 @@ def main(args):
123131
hooks_config['configuration']['debug'] = debug
124132

125133
# delete entire graph ?
126-
if "delete" in hooks_config['configuration']:
127-
logging.info("Deleting all nodes in graph database")
128-
graph.delete_all()
129-
# reset GraphQL IDL
130-
graph.run("CALL graphql.idl(null)")
134+
try:
135+
delete = hooks_config['configuration']['delete']
136+
except KeyError:
137+
pass
138+
else:
139+
if delete:
140+
logging.info("Deleting all nodes in graph database")
141+
graph.delete_all()
142+
# reset GraphQL IDL
143+
graph.run("CALL graphql.idl(null)")
144+
145+
# replace existing OS ?
146+
os_match = OS.match(graph).where("_.name = '{}'".format(vm_name))
147+
try:
148+
replace = hooks_config['configuration']['replace']
149+
except KeyError:
150+
# assume replace = False
151+
if os_match.first():
152+
logging.info('OS already inserted, exiting')
153+
return
154+
else:
155+
if not replace and os_match.first():
156+
logging.info('OS already inserted, exiting')
157+
return
158+
elif os_match.first():
159+
# replace = True and an OS already exists
160+
logging.info('Deleting previous OS')
161+
graph.run(SUBGRAPH_DELETE_OS.format(vm_name))
131162

132163
with QEMUDomainContextFactory(vm_name, uri) as context:
133164
with Environment(context, hooks_config) as environment:
165+
logging.info('Capturing %s', vm_name)
134166
protocol(environment)

oswatcher/model.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66

77
class OS(GraphObject):
88

9-
def __init__(self, name):
9+
def __init__(self, name, release_date):
1010
super().__init__()
1111
self.name = name
12+
self.release_date = release_date
1213

1314
# properties
1415
name = Property()
16+
release_date = Property()
1517

1618
# relationships
1719
root_fileystem = RelatedTo("Inode", "OWNS_FILESYSTEM")

packer-templates/import_libvirt.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
"""
44
Usage:
5-
import_libvirt.py [options] <disk_image>
5+
import_libvirt.py [options] <disk_image>...
66
77
Options:
88
-h --help Show this screen.
@@ -36,14 +36,14 @@ def prepare_domain_xml(vm_name, osw_image_path):
3636
return domain_xml
3737

3838

39-
def setup_storage_pool(con, pool_name, pool_path):
39+
def setup_storage_pool(con, pool_name):
4040
# check for storage pool
4141
try:
4242
pool = con.storagePoolLookupByName(pool_name)
4343
except libvirt.libvirtError:
4444
# build oswatcher pool xml
4545
path_elem = tree.Element('path')
46-
path_elem.text = str(pool_path)
46+
path_elem.text = str(POOL_PATH)
4747
target_elem = tree.Element('target')
4848
target_elem.append(path_elem)
4949
name_elem = tree.Element('name')
@@ -62,7 +62,11 @@ def setup_storage_pool(con, pool_name, pool_path):
6262
if not pool.isActive():
6363
pool.build()
6464
pool.create()
65-
return pool
65+
xml = pool.XMLDesc()
66+
root = tree.fromstring(xml)
67+
path_elem = root.findall('./target/path')[0]
68+
pool_path = Path(path_elem.text)
69+
return pool, pool_path
6670

6771

6872
def append_qcow(disk_image):
@@ -71,7 +75,7 @@ def append_qcow(disk_image):
7175
return disk_image
7276

7377

74-
def setup_domain(con, vm_name, pool, disk_image, pool_path):
78+
def setup_domain(con, vm_name, pool, pool_path, disk_image):
7579
# check if domain is already defined
7680
domain_name = '{}-{}'.format(PREFIX, disk_image.stem)
7781
if not vm_name:
@@ -101,18 +105,20 @@ def main(args):
101105
log_level = logging.DEBUG
102106
logging.basicConfig(level=log_level, format='%(message)s')
103107

104-
disk_image = Path(args['<disk_image>']).absolute()
108+
disk_image_list = args['<disk_image>']
105109
pool_name = args['--pool-name']
106110
vm_name = args['--vm-name']
107111
uri = args['--connection']
108112
con = libvirt.open(uri)
109113

110-
pool = setup_storage_pool(con, pool_name, POOL_PATH)
111-
setup_domain(con, vm_name, pool, disk_image, POOL_PATH)
112-
# remove output-qemu
113-
logging.info('Removing output-qemu')
114-
output_qemu_path = Path(SCRIPT_DIR / PACKER_OUTPUT_DIR)
115-
shutil.rmtree(str(output_qemu_path))
114+
for disk_image in disk_image_list:
115+
disk_image = Path(disk_image).absolute()
116+
pool, pool_path = setup_storage_pool(con, pool_name)
117+
setup_domain(con, vm_name, pool, pool_path, disk_image)
118+
# remove output-qemu
119+
logging.info('Removing output-qemu')
120+
output_qemu_path = Path(SCRIPT_DIR / PACKER_OUTPUT_DIR)
121+
shutil.rmtree(str(output_qemu_path), ignore_errors=True)
116122

117123

118124
if __name__ == '__main__':

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ docopt
22
libvirt-python
33
py2neo
44
python-see
5+
seaborn

0 commit comments

Comments
 (0)