Skip to content

Commit 1e4c27f

Browse files
authored
Merge pull request #171 from trailofbits/fix-ast-unparse-crash
Fix ast.unparse() crash with malformed pickle files
2 parents 5ceadcd + 23f327d commit 1e4c27f

2 files changed

Lines changed: 184 additions & 6 deletions

File tree

fickling/fickle.py

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import ast
4+
import keyword
45
import marshal
56
import re
67
import struct
@@ -44,6 +45,63 @@ def is_std_module(module_name: str) -> bool:
4445
return in_stdlib(module_name) or module_name in BUILTIN_MODULE_NAMES
4546

4647

48+
def extract_identifier_from_ast_node(
49+
node: ast.expr | str, fallback_prefix: str = "_malformed"
50+
) -> str:
51+
"""
52+
Extract a valid Python identifier from an AST node.
53+
54+
For malformed pickle files where STACK_GLOBAL receives complex AST nodes
55+
instead of strings, this function attempts to extract a meaningful identifier
56+
or generates a safe fallback.
57+
58+
Args:
59+
node: An AST expression node or string
60+
fallback_prefix: Prefix for generated fallback identifiers
61+
62+
Returns:
63+
A valid Python identifier string
64+
"""
65+
# If already a string, validate and return or fix it
66+
if isinstance(node, str):
67+
if node.isidentifier() and not keyword.iskeyword(node):
68+
return node
69+
# For invalid string identifiers, create a safe fallback
70+
node_hash = abs(hash(node)) % 100000
71+
return f"{fallback_prefix}_str_{node_hash}"
72+
73+
# Handle ast.Name - extract the id attribute
74+
if isinstance(node, ast.Name):
75+
return node.id
76+
77+
# Handle ast.Attribute - return the attribute name
78+
if isinstance(node, ast.Attribute):
79+
return node.attr
80+
81+
# Handle ast.Call - extract identifier from the function being called
82+
if isinstance(node, ast.Call):
83+
return extract_identifier_from_ast_node(node.func, fallback_prefix)
84+
85+
# Handle ast.Constant - extract value if it's a valid identifier string
86+
if isinstance(node, ast.Constant):
87+
value = node.value
88+
if isinstance(value, str):
89+
if value.isidentifier() and not keyword.iskeyword(value):
90+
return value
91+
# For invalid constant strings, create a deterministic fallback
92+
value_hash = abs(hash(value)) % 100000
93+
return f"{fallback_prefix}_const_{value_hash}"
94+
# For non-string constants (int, float, etc.), create a type-based fallback
95+
type_name = type(value).__name__
96+
value_hash = abs(hash(value)) % 100000
97+
return f"{fallback_prefix}_{type_name}_{value_hash}"
98+
99+
# For any other complex node types, generate a safe placeholder
100+
node_type = type(node).__name__.lower()
101+
node_hash = abs(hash(id(node))) % 100000
102+
return f"{fallback_prefix}_{node_type}_{node_hash}"
103+
104+
47105
class MarkObject:
48106
pass
49107

@@ -1060,20 +1118,45 @@ class StackGlobal(NoOp):
10601118
def run(self, interpreter: Interpreter):
10611119
attr = interpreter.stack.pop()
10621120
module = interpreter.stack.pop()
1121+
1122+
# Extract values from ast.Constant nodes
10631123
if isinstance(module, ast.Constant):
10641124
module = module.value
10651125
if isinstance(attr, ast.Constant):
10661126
attr = attr.value
10671127

1068-
# normalize module and attr to strings
1069-
if not isinstance(module, str) or not isinstance(attr, str):
1128+
# Normalize module and attr to strings, extracting meaningful identifiers from AST nodes
1129+
module_needs_extraction = not isinstance(module, str)
1130+
attr_needs_extraction = not isinstance(attr, str)
1131+
1132+
if module_needs_extraction or attr_needs_extraction:
10701133
sys.stdout.write(
10711134
f"Warning: malformed pickle file. STACK_GLOBAL called with invalid types. "
10721135
f"'Module' is {type(module).__name__} ({module!r}), 'Attr' is {type(attr).__name__} ({attr!r}). "
1073-
f"Expected str; casting to string to continue analysis.\n"
1136+
f"Expected str; extracting identifiers to continue analysis.\n"
10741137
)
1075-
module = str(module)
1076-
attr = str(attr)
1138+
1139+
if module_needs_extraction:
1140+
module = extract_identifier_from_ast_node(
1141+
module, fallback_prefix="_malformed_module"
1142+
)
1143+
if attr_needs_extraction:
1144+
attr = extract_identifier_from_ast_node(attr, fallback_prefix="_malformed_attr")
1145+
1146+
# Final validation: ensure both are valid identifier strings
1147+
if not isinstance(module, str) or not isinstance(attr, str):
1148+
raise TypeError(
1149+
f"Failed to extract valid identifiers from STACK_GLOBAL arguments. "
1150+
f"Module: {type(module).__name__}, Attr: {type(attr).__name__}"
1151+
)
1152+
1153+
if not module.isidentifier() or not attr.isidentifier():
1154+
raise ValueError(
1155+
f"Extracted identifiers are not valid Python identifiers. "
1156+
f"Module: {module!r}, Attr: {attr!r}"
1157+
)
1158+
1159+
# Continue with normal processing
10771160
if module in ("__builtin__", "__builtins__", "builtins"):
10781161
# no need to emit an import for builtins!
10791162
pass
@@ -1133,6 +1216,10 @@ class BinPut(Opcode):
11331216
def run(self, interpreter: Interpreter):
11341217
interpreter.memory[self.arg] = interpreter.stack[-1]
11351218

1219+
def encode_body(self):
1220+
assert self.arg <= 255, "BINPUT only supports 1-byte memo indexing"
1221+
return bytes([self.arg])
1222+
11361223

11371224
class LongBinPut(BinPut):
11381225
name = "LONG_BINPUT"
@@ -1422,6 +1509,10 @@ def run(self, interpreter: Interpreter):
14221509
else:
14231510
interpreter.stack.append(interpreter.memory[self.arg])
14241511

1512+
def encode_body(self):
1513+
assert self.arg <= 255, "BINGET only supports 1-byte memo indexing"
1514+
return bytes([self.arg])
1515+
14251516

14261517
class LongBinGet(Opcode):
14271518
name = "LONG_BINGET"

test/test_crashes.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,23 @@
88
from functools import wraps
99
from unittest import TestCase
1010

11-
from fickling.fickle import Pickled
11+
from fickling.fickle import (
12+
BinGet,
13+
BinInt1,
14+
BinPut,
15+
BinUnicode,
16+
Global,
17+
Mark,
18+
Memoize,
19+
Pickled,
20+
Pop,
21+
Proto,
22+
Reduce,
23+
ShortBinUnicode,
24+
StackGlobal,
25+
Stop,
26+
Tuple,
27+
)
1228

1329

1430
def unparse_test(pickled: bytes):
@@ -60,3 +76,74 @@ def test_pop_mark(self):
6076
def test_obj(self):
6177
"""Tests the correctness of the OBJ opcode"""
6278
pass
79+
80+
# Based on the CTF challenge shared in https://github.com/trailofbits/fickling/issues/125.
81+
def test_stack_global_dynamic_import(self):
82+
alphabet = (
83+
"Jw~[v5QpA(BY%aKnyT&*x0r9-OpfF}HN4$GU2VhS@XEq!Zt>6_R7#]1b{z3M^D?)d8eImgckPLiuoClW<js"
84+
)
85+
pickled = Pickled(
86+
[
87+
Proto.create(4),
88+
# Save itemgetter to memo[0]
89+
Global.create("operator", "itemgetter"),
90+
BinPut(0),
91+
Pop(),
92+
# _var0 = getattr('', 'join')
93+
Global.create("builtins", "getattr"),
94+
Mark(),
95+
ShortBinUnicode(""),
96+
ShortBinUnicode("join"),
97+
Tuple(),
98+
Reduce(),
99+
Memoize(), # memo[1] = join method
100+
# _var1 = itemgetter(77, 83)(ALPHABET) -> ('o', 's')
101+
BinGet(0),
102+
Mark(),
103+
BinInt1(77),
104+
BinInt1(83),
105+
Tuple(),
106+
Reduce(), # itemgetter(77, 83)
107+
Mark(),
108+
BinUnicode(alphabet),
109+
Tuple(),
110+
Reduce(),
111+
Memoize(), # memo[2] = ('o', 's')
112+
# _var2 = ''.join(_var1) -> "os"
113+
BinGet(1),
114+
Mark(),
115+
BinGet(2),
116+
Tuple(),
117+
Reduce(),
118+
Memoize(), # memo[3] = "os"
119+
# _var3 = itemgetter(83, 16, 83, 47, 67, 69)(ALPHABET) -> ('s','y','s','t','e','m')
120+
BinGet(0),
121+
Mark(),
122+
BinInt1(83),
123+
BinInt1(16),
124+
BinInt1(83),
125+
BinInt1(47),
126+
BinInt1(67),
127+
BinInt1(69),
128+
Tuple(),
129+
Reduce(), # itemgetter(...)
130+
Mark(),
131+
BinUnicode(alphabet),
132+
Tuple(),
133+
Reduce(),
134+
Memoize(), # memo[4] = ('s','y','s','t','e','m')
135+
# _var4 = ''.join(_var3) -> "system"
136+
BinGet(1),
137+
Mark(),
138+
BinGet(4),
139+
Tuple(),
140+
Reduce(),
141+
Memoize(), # memo[5] = "system"
142+
# from _var2 import _var4 (dynamic import via StackGlobal)
143+
BinGet(3), # "os"
144+
BinGet(5), # "system"
145+
StackGlobal(),
146+
Stop(),
147+
]
148+
)
149+
unparse(pickled.ast)

0 commit comments

Comments
 (0)