Skip to content

Commit

Permalink
Merge pull request #641 from tomato42/advanced-decision-tree
Browse files Browse the repository at this point in the history
Advanced uses of decision graph
  • Loading branch information
tomato42 committed Feb 20, 2020
2 parents 354438e + b194a1d commit 22a9426
Show file tree
Hide file tree
Showing 5 changed files with 206 additions and 20 deletions.
1 change: 1 addition & 0 deletions .github/styles/vocab.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ fuzzers
GnuTLS
hostname
http
interoperate
kario
khaitovich
OpenSSL
Expand Down
170 changes: 170 additions & 0 deletions docs/source/advanced-decision-graph.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
.. _Decision graph:

==============
Decision graph
==============

While this documentation calls the structure traversed by the runner a
"decision graph," as it can contain loops, it's more precisely described as a
directed graph. Older parts of this documentation and object names refer to
this structure as a "decision tree"—this name
reflects the most common use case in the bundled tests, not the most
complex supported one.

Node fields
===========

A decision graph node has two pointers, to a ``child`` and to a ``next_sibling``.
On initialisation nodes set them to ``None``.

Child nodes
-----------

When a node matches received message and processes it without errors,
runner continues execution by switching to the child.

If ``child`` points to ``None``, runner closes open connections and ends
execution.

To create loops the ``child`` can point to itself or nodes that point to it,
either directly or transitively.
You need to use this mechanism to allow receiving arbitrary number of messages.

Sibling nodes
--------------

The runner uses nodes pointed to by ``next_sibling`` when received message
doesn't match the current node. When sending messages, runner looks
into ``next_sibling`` when connection got closed.

You can use this mechanism to either break out of loops or to define
alternatives in execution.

Advanced decision graph structures
==================================

As mentioned before, the decision graph allows for non-linear relationship
between nodes.

Loops
-----

Test case runner in tlsfuzzer can accept arbitrary number of messages if
the node points to itself as its child.

For example, to accept zero or more NewSessionTicket messages in
:term:`TLS` 1.3 connection, the script needs to include the following code:

.. code:: python
cycle = ExpectNewSessionTicket()
node = node.add_child(cycle)
node.add_child(cycle)
Servers that send the NewSessionTicket after Finished and before
any other messages, require the preceding code after
:py:class:`~tlsfuzzer.expect.ExpectFinished`.
That handles OpenSSL-using servers and others that behave similarly.

.. note::
:term:`TLS` standard does allow sending NewSessionTicket messages at
arbitrary times after Finished.

Write the following code to make the runner finish the loop once an
ApplicationData message is received:

.. code:: python
node.next_sibling = ExpectApplicationData()
node = node.next_sibling
.. tip::
If you want to accept arbitrary number of NewSessionTicket messages, but
no fewer than a specified amount, add more
:py:class:`~tlsfuzzer.expect.ExpectNewSessionTicket` nodes before the
loop to ensure that server sends them.

You can find a working example of this code in
`test-tls13-conversation.py
<https://github.com/tomato42/tlsfuzzer/blob/master/scripts/test-tls13-conversation.py>`_.

Alternatives
------------

Servers configured with client certificate based authentication send
CertificateRequest message.
For a script to interoperate with such servers it needs to expect that message.
If a client receives it, it needs to reply with a Certificate message,
even if it doesn't have a certificate (it sends an empty message then).
Since a node doesn't have a limit on the number of parent nodes, script
can specify a branch to handle such connections.

Start with specifying the exceptional path, save reference to the fork point:

.. code:: python
node = node.add_child(ExpectCertificateRequest())
fork = node
node = node.add_child(ExpectServerHelloDone())
node = node.add_child(CertificateGenerator())
Then specify the usual path, for servers that don't ask for client
certificates:

.. code:: python
fork.next_sibling = ExpectServerHelloDone()
In both handshake scenarios the client sends ClientKeyExchange message,
this joins the paths:

.. code:: python
join = ClientKeyExchangeGenerator()
# join regular path:
fork.next_sibling.add_child(join)
# join CR path:
node = node.add_child(join)
After that, handshake continues as usual with ChangeCipherSpec, Finished, etc.

.. note::
When specifying alternative messages, you must take care not to allow
message exchanges forbidden by the standards.
Place all the messages that depend on the branch in the branch to ensure
that (but check if using a command line switch to build different graphs
doesn't lead to simpler test scripts).

You can find a working example of this code in
`test-fuzzed-plaintext.py
<https://github.com/tomato42/tlsfuzzer/blob/master/scripts/test-fuzzed-plaintext.py>`_.

Error handling
--------------

If you want to allow the server to abort connection while *sending* data,
use the sibling mechanism too.

To allow the server to close the connection while writing to it,
specify the :py:class:`~tlsfuzzer.expect.ExpectClose` as sibling of the node:

.. code:: python
node = node.add_child(CertificateVerifyGenerator(private_key))
node.next_sibling = ExpectClose()
node = node.add_child(ChangeCipherSpecGenerator())
node.next_sibling = ExpectClose()
node = node.add_child(FinishedGenerator())
node.next_sibling = ExpectClose()
Use :py:class:`~tlsfuzzer.expect.ExpectAlert` the same way.

.. note::
Runner supports only :py:class:`~tlsfuzzer.expect.ExpectAlert` and
:py:class:`~tlsfuzzer.expect.ExpectClose` as siblings of generator nodes.
Since connection close triggers this path, you can read only already
buffered messages.

You can find a working example of this code in
`test-certificate-verify-malformed-sig.py
<https://github.com/tomato42/tlsfuzzer/blob/master/scripts/test-certificate-verify-malformed-sig.py>`_.
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ to see wanted, but not yet implemented features.
installation
theory
writing-tests
advanced-decision-graph
glossary
modules

Expand Down
30 changes: 16 additions & 14 deletions docs/source/writing-tests.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ Test creation
Network servers use connection timeouts to drop stalled or unused connections.
For some that happens in a minute or two, for others in seconds.
Thus, robust test cases require automation.
tlsfuzzer achieves it through a runner that executes decision trees.
tlsfuzzer achieves it through a runner that executes decision graphs.

The test scripts included in ``scripts/`` directory build the decision trees
necessary for testing different scenarios. After building a tree, the runner
The test scripts included in ``scripts/`` directory build the decision graph
necessary for testing different scenarios. After building a graph, the runner
executes it and provides a test result (by raising an exception in case of
errors).
The example below builds a single tree and executes it.
The example below builds a single graph and executes it.

Building decision trees
Building decision graph
=======================

To exchange :term:`TLS` messages the script needs to establish a :term:`TCP`
Expand Down Expand Up @@ -136,7 +136,7 @@ To advertise it, send an empty ``renegotiation_info`` extension, like so:
extensions[ExtensionType.renegotiation_info] = renego_ext
After preparing all extensions, create the ClientHello object and attach it to
the decision tree:
the decision graph:

.. code:: python
Expand Down Expand Up @@ -218,8 +218,8 @@ To perform a single ``GET`` with HTTP 1.0, use the following:
node = node.add_child(ApplicationDataGenerator(request))
node = node.add_child(ExpectApplicationData())
Closing the connection (alternatives in decision trees)
-------------------------------------------------------
Closing the connection (alternatives in decision graphs)
--------------------------------------------------------

To handle slight differences between different ways that servers behave,
the framework allows specifying alternatives for the
Expand Down Expand Up @@ -256,19 +256,21 @@ an Alert before connection close, use the following code:
node.next_sibling = ExpectClose()
node.add_child(ExpectClose())
With no more nodes in the tree, the runner closes the connection
With no more nodes in the graph, the runner closes the connection
and ignores any data in buffers.
:py:class:`~.tlsfuzzer.expect.ExpectClose` instead verifies that server didn't
send any messages before closing the socket.

Executing decision trees
========================
You can read more about alternatives in the :ref:`Decision graph` chapter.

Executing decision graphs
=========================

If you tried to execute this example script now, nothing would happen.
To actually connect to a server and exchange messages, the runner needs to
execute the decision tree.
execute the decision graph.

As an argument the runner takes the root of the decision tree.
As an argument the runner takes the root of the decision graph.
In case of unmet expectations (:term:`TCP` connection failure, misbehaviour
by the server, etc.) the runner raises an exception.

Expand All @@ -279,7 +281,7 @@ To prepare it execute:
from tlsfuzzer.runner import Runner
runner = Runner(root_node)
To execute the decision tree:
To execute the decision graph:

.. code:: python
Expand Down
24 changes: 18 additions & 6 deletions scripts/test-fuzzed-plaintext.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,17 +96,21 @@ def add_app_data_conversation(conversations, host, port, cipher, dhe, data):
node = node.add_child(ExpectCertificate())
if dhe:
node = node.add_child(ExpectServerKeyExchange())

# handle servers that ask for client certificates
node = node.add_child(ExpectCertificateRequest())
fork = node
node = node.add_child(ExpectServerHelloDone())
node = node.add_child(CertificateGenerator())

# handle servers which ask for client certificates
# handle servers that don't ask for client certificates
fork.next_sibling = ExpectServerHelloDone()

# join both paths
join = ClientKeyExchangeGenerator()
fork.next_sibling.add_child(join)

node = node.add_child(join)

node = node.add_child(ChangeCipherSpecGenerator())
node = node.add_child(FinishedGenerator())
node = node.add_child(ExpectChangeCipherSpec())
Expand Down Expand Up @@ -137,17 +141,21 @@ def add_handshake_conversation(conversations, host, port, cipher, dhe, data):
node = node.add_child(ExpectCertificate())
if dhe:
node = node.add_child(ExpectServerKeyExchange())

# handle servers that ask for client certificates
node = node.add_child(ExpectCertificateRequest())
fork = node
node = node.add_child(ExpectServerHelloDone())
node = node.add_child(CertificateGenerator())

# handle servers which ask for client certificates
# handle servers that don't ask for client certificates
fork.next_sibling = ExpectServerHelloDone()

# join both paths
join = ClientKeyExchangeGenerator()
fork.next_sibling.add_child(join)

node = node.add_child(join)

node = node.add_child(ChangeCipherSpecGenerator())
node = node.add_child(replace_plaintext(
FinishedGenerator(),
Expand Down Expand Up @@ -271,17 +279,21 @@ def main():
node = node.add_child(ExpectCertificate())
if dhe:
node = node.add_child(ExpectServerKeyExchange())

# handle servers that ask for client certificates
node = node.add_child(ExpectCertificateRequest())
fork = node
node = node.add_child(ExpectServerHelloDone())
node = node.add_child(CertificateGenerator())

# handle servers which ask for client certificates
# handle servers that don't ask for client certificates
fork.next_sibling = ExpectServerHelloDone()

# join both paths
join = ClientKeyExchangeGenerator()
fork.next_sibling.add_child(join)

node = node.add_child(join)

node = node.add_child(ChangeCipherSpecGenerator())
node = node.add_child(FinishedGenerator())
node = node.add_child(ExpectChangeCipherSpec())
Expand Down

0 comments on commit 22a9426

Please sign in to comment.