YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
Joblib sklearn Pipeline β CountVectorizer vocabulary_ Feature-Column Binding Mutation
Summary
A crafted Joblib artifact containing a fitted sklearn pipeline can cause the pipeline's CountVectorizer to map input tokens to wrong feature columns at inference time, silently changing the predicted class. The attack mutates CountVectorizer.vocabulary_ β the token-to-column-index mapping β within the serialized artifact. When the artifact is loaded and pipeline.predict() is called, the same raw text input activates a different feature column in the sparse matrix, causing the downstream classifier to apply a different learned coefficient and produce a different predicted class.
The classifier's numeric parameters (coef_, intercept_, classes_), the pipeline structure, and the raw text input are all unchanged. Only the token-to-feature-column binding is altered.
This is not a code-execution finding. There is no __reduce__ method, no os.system call, and no arbitrary code execution. The vulnerability is that CountVectorizer._count_vocab() reads self.vocabulary_ at inference time without any integrity validation against the downstream classifier's coefficient layout.
Affected Product
- Package: scikit-learn (via joblib serialization)
- Format: Joblib (.joblib)
- Affected attribute:
CountVectorizer.vocabulary_(also applicable toTfidfVectorizer.vocabulary_) - Affected method:
CountVectorizer._count_vocab()βsklearn/feature_extraction/text.py
Vulnerability Details
CountVectorizer._count_vocab() contains the following logic at transform time (after fit(), fixed_vocab=True):
def _count_vocab(self, raw_documents, fixed_vocab):
if fixed_vocab:
vocabulary = self.vocabulary_ # <-- read directly from fitted state
...
for feature in analyze(doc):
feature_idx = vocabulary[feature] # <-- token -> column index (no validation)
feature_idx directly determines which column in the sparse feature matrix receives the count. LogisticRegression.coef_[0, j] was trained for column j. If vocabulary_['apple'] is changed from 0 to 4, the token "apple" now contributes to column 4, and the classifier reads coef_[4] instead of coef_[0].
No validation exists to confirm that the index values stored in vocabulary_ are consistent with the coefficient layout of the downstream classifier. A crafted artifact with two index values swapped is syntactically valid: key set unchanged, all values remain in [0, n_features), all values unique. No IndexError, ValueError, or warning is raised at inference time.
Proof of Concept
Setup
Pipeline: CountVectorizer() β LogisticRegression(random_state=42, max_iter=1000)
Corpus:
Class 0: 'apple apple apple', 'apple apple', 'apple fruit', 'banana banana banana', 'banana fruit'
Class 1: 'orange orange orange', 'orange orange', 'orange citrus'
vocabulary_: {'apple': 0, 'banana': 1, 'citrus': 2, 'fruit': 3, 'orange': 4}
coef_: [-0.622, -0.487, +0.342, -0.338, +1.018]
apple banana citrus fruit orange
Mutation (applied before joblib.dump)
pipe.named_steps['cv'].vocabulary_['apple'] = 4 # was 0
pipe.named_steps['cv'].vocabulary_['orange'] = 0 # was 4
Result
Test input: ['apple juice']
CLEAN predict=[0] score=-1.325 feature=[[1,0,0,0,0]]
apple -> col 0 -> coef_[0]=-0.622 (class 0 activator)
MUTANT predict=[1] score=+0.316 feature=[[0,0,0,0,1]]
apple -> col 4 -> coef_[4]=+1.018 (orange's coefficient, class 1 activator)
Prediction class index: 0 -> 1 (FLIPPED)
Decision score sign: - -> + (FLIPPED)
coef_ unchanged: [-0.622, -0.487, +0.342, -0.338, +1.018] identical in both
Reproduction
pip install -r requirements.txt
python reproduce_joblib_vocabulary_feature_binding_flip.py
# Expected: JOBLIB_VOCABULARY_FEATURE_BINDING_FLIP_CONFIRMED
python inspect_joblib_vocabulary_feature_binding_hash_matrix.py
# Expected: JOBLIB_VOCABULARY_FEATURE_BINDING_HASH_MATRIX_PASS
Evidence
evidence_runtime_results.jsonβ T0 runtime results (12 assertions, 5/5 reproducibility)evidence_hash_matrix.jsonβ SHA256 of both artifacts, field-level diffevidence_distinctness_vs_classes.jsonβ 6-dimensional distinctness table vs prior classes_ findingevidence_top_axis.jsonβ gate summary (5/5 gates pass)
Feature Binding Semantics
vocabulary_ is a runtime-consumed feature-binding table, not a numeric classifier weight. Its values determine which position in the sparse feature matrix a token contributes to, and therefore which trained coefficient is applied. This is a pre-computation binding, not a post-computation label substitution.
The mutation changes the mathematical computation path through the pipeline: the same raw text input produces a different feature vector, which is dot-producted against an unchanged coef_ array, producing a different decision score and different predicted class index.
Distinctness from Prior classes_ Finding
| Dimension | vocabulary_ (this finding) | classes_ (prior submission) |
|---|---|---|
| Pipeline stage | INPUT encoding (pre-computation) | OUTPUT labeling (post-computation) |
| Field type | dict: token β column_index | array: class_index β label_string |
| Effect | Prediction class index changes | Prediction string changes only |
| Prediction change | Class index 0β1, score sign flips | String changes, score/index identical |
| Fix location | CountVectorizer._count_vocab() | predict() output layer |
| Sklearn class | CountVectorizer / TfidfVectorizer | LogisticRegression / SVC / any classifier |
Non-Claims
- This finding does not claim arbitrary code execution
- This finding does not claim RCE or ACE
- This finding does not use
__reduce__,os.system, or any code injection mechanism - This finding does not claim the vulnerability bypasses a security scanner as a primary impact
- This finding does not claim that no model state changed β
vocabulary_is model state
Recommendation
Add an integrity check in CountVectorizer.transform() that validates vocabulary_ index values are consistent with the classifier's expected input dimensions. Options include: (a) storing a hash of vocabulary_ alongside coef_ at fit time and verifying at inference, or (b) validating that the set of index values in vocabulary_ exactly matches {0, ..., n_features_in_ - 1} at the start of transform().
References
- scikit-learn source:
sklearn/feature_extraction/text.pyCountVectorizer._count_vocab() - Joblib documentation: https://joblib.readthedocs.io/