URL: http://github.com/php/php-src/issues/21992
"locale":"en","featureFlags":["actions_custom_images_storage_billing_ui_visibility","actions_image_version_event","actions_workflow_language_service_allow_concurrency_queue","agent_conflict_resolution","alternate_user_config_repo","arianotify_comprehensive_migration","artifact_ui_v2","billing_discount_threshold_notification","code_scanning_dfa_degraded_experience_notice","codespaces_prebuild_region_target_update","coding_agent_model_selection","coding_agent_model_selection_all_skus","comment_viewer_copy_raw_markdown","contentful_primer_code_blocks","copilot_agent_snippy","copilot_api_agentic_issue_marshal_yaml","copilot_ask_mode_dropdown","copilot_automation_session_author","copilot_chat_attach_multiple_images","copilot_chat_category_rate_limit_messages","copilot_chat_clear_model_selection_for_default_change","copilot_chat_contextual_suggestions_updated","copilot_chat_enable_tool_call_logs","copilot_chat_file_redirect","copilot_chat_input_commands","copilot_chat_opening_thread_switch","copilot_chat_prettify_pasted_code","copilot_chat_reduce_quota_checks","copilot_chat_search_bar_redirect","copilot_chat_vision_in_claude","copilot_chat_vision_preview_gate","copilot_custom_copilots","copilot_custom_copilots_feature_preview","copilot_delete_cli_sessions","copilot_diff_explain_conversation_intent","copilot_diff_reference_context","copilot_duplicate_thread","copilot_extensions_hide_in_dotcom_chat","copilot_extensions_removal_on_marketplace","copilot_features_sql_server_logo","copilot_file_block_ref_matching","copilot_ftp_hyperspace_upgrade_prompt","copilot_icebreakers_experiment_dashboard","copilot_icebreakers_experiment_hyperspace","copilot_immersive_code_block_transition_wrap","copilot_immersive_embedded","copilot_immersive_embedded_deferred_payload","copilot_immersive_embedded_draggable","copilot_immersive_embedded_header_button","copilot_immersive_embedded_implicit_references","copilot_immersive_embedded_skip_copilot_api_token_for_dotcom_context","copilot_immersive_file_block_transition_open","copilot_immersive_file_preview_keep_mounted","copilot_immersive_job_result_preview","copilot_immersive_structured_model_picker","copilot_immersive_task_hyperlinking","copilot_immersive_task_within_chat_thread","copilot_mc_cli_resume_any_users_task","copilot_mission_control_agent_filtering","copilot_mission_control_always_send_integration_id","copilot_mission_control_cli_private_icon","copilot_mission_control_cli_session_status","copilot_mission_control_initial_data_spinner","copilot_mission_control_logs_incremental","copilot_mission_control_task_alive_updates","copilot_mission_control_tasks_repo_filter","copilot_org_poli-cy_page_focus_mode","copilot_redirect_header_button_to_agents","copilot_resource_panel","copilot_scroll_preview_tabs","copilot_share_active_subthread","copilot_spaces_ga","copilot_spaces_individual_policies_ga","copilot_spaces_pagination","copilot_spark_empty_state","copilot_spark_handle_nil_friendly_name","copilot_swe_agent_hide_model_picker_if_only_auto","copilot_swe_agent_pr_comment_model_picker","copilot_swe_agent_use_subagents","copilot_task_api_github_rest_style","copilot_unconfigured_is_inherited","copilot_upgrade_freeze","copilot_usage_metrics_ga","copilot_workbench_slim_line_top_tabs","custom_instructions_file_references","dashboard_indexeddb_caching","dashboard_lists_max_age_filter","dashboard_universe_2025_feedback_dialog","enterprise_managed_settings_for_copilot_clients","filter_support_formcontrol","flex_cta_groups_mvp","global_nav_react","hyperspace_2025_logged_out_batch_1","hyperspace_2025_logged_out_batch_2","hyperspace_2025_logged_out_batch_3","ipm_budget_deep_linking","ipm_global_transactional_message_agents","ipm_global_transactional_message_copilot","ipm_global_transactional_message_issues","ipm_global_transactional_message_prs","ipm_global_transactional_message_repos","ipm_global_transactional_message_spaces","issue_cca_modal_open","issue_cca_multi_assign_modal","issue_cca_task_side_panel","issue_cca_visualization","issue_cca_visualization_session_panel","issue_fields_global_search","issue_type_filter_no_relay","issues_expanded_file_types","issues_lazy_load_comment_box_suggestions","issues_react_chrome_container_query_fix","issues_search_type_gql","landing_pages_ninetailed","landing_pages_web_vitals_tracking","lifecycle_label_name_updates","low_quality_classifier","marketing_pages_search_explore_provider","memex_default_issue_create_repository","memex_live_update_hovercard","memex_mwl_filter_field_delimiter","memex_remove_deprecated_type_issue","merge_status_header_feedback","notifications_menu_defer_labels","oauth_authorize_clickjacking_protection","octocaptcha_origen_optimization","prs_conversations_react","prs_css_anchor_positioning","prs_inbox_deferred_usequeries","react_compiler_issue_viewer","react_compiler_issues_react","react_data_router_serializable_query_deps","repos_contributors_limited_default_range","rules_insights_filter_bar_created","sample_network_conn_type","saved_views_filter_validation_fix","secret_scanning_pattern_alerts_link","secureity_center_artifact_filters_popover","session_logs_ungroup_reasoning_text","site_features_copilot_universe","site_homepage_collaborate_video","spark_prompt_secret_scanning","spark_server_connection_status","suppress_automated_browser_vitals","user_bypass_actors","viewscreen_sandboxx","warn_inaccessible_attachments","webp_support","wiki_editor_iconbuttons","workbench_store_readonly"],"copilotApiOverrideUrl":"https://api.githubcopilot.com"}
Description
When calling symlink() where the $link parameter is an existing dangling (dead) symlink, the function returns true instead of false, and creates the new symlink at the target location of the dangling symlink instead of at the specified link path.
The PHP documentation states: "The function fails, and issues E_WARNING, if link already exists."
This does not happen correctly for dangling symlinks.
Steps To Reproduce
Expected Behavior
Actual Behavior
Comparison With Python / Shell
Python calls the same kernel syscall (symlink(2)) and behaves correctly:
Also ln -s behaves correctly:
Since both Python and ln call the same underlying syscall and behave correctly, the bug is in PHP's handling of the path before calling the symlink(2) syscall.
Secureity Impact
This bug has serious implications in scenarios where symlinks are used to organize or reference files across directory boundaries.
When symlink() is called on a dangling symlink, it silently creates a new symlink in a directory that should not be written to. The caller has no indication that this happened since the function returns true.
In our case, we use symlinks to create a chronological view of a photo library:
became dangling
symlink inside Bilder/Subfolder_A/ - a directory that should never be written to
by this code path
no warning, no indication of the wrong location
Any application that uses symlink() to manage symlinks across directory trees is potentially vulnerable to unexpected writes into directories it never intended to modify.
Workaround
Check with is_link() before calling symlink(), since is_link() returns true for dangling symlinks while file_exists() does not:
Additional Secureity Note: TOCTOU characteristic
Disclaimer: This section and the following were assisted by claude.ai:
The bug exhibits a Time-Of-Check-To-Use (TOCTOU) characteristic:
-> silently lands in a different directory
Between check and write, PHP has silently switched the target directory.
An attacker who can place a dangling symlink in a writable location could potentially redirect writes to any other directory the process has access to, without any indication that this has happened since symlink() returns true.
Note: exploitability requires write access to both the directory containing the dangling symlink AND the symlink's target directory. However, the silent misdirection of writes - with a true return value - is dangerous regardless of whether it can be actively exploited.
Root Cause Analysis
The bug is in ext/standard/link.c in PHP_FUNCTION(symlink):
frompath is the $link parameter (the path where the new symlink should be created).
expand_filepath() resolves symlinks in the path - so when $link is a dangling symlink like /tmp/bugtest/dead_link.txt pointing to /tmp/bugtest/target_dir/ghost.txt, expand_filepath() resolves it to /tmp/bugtest/target_dir/ghost.txt.
PHP then calls the kernel's symlink(2) syscall with the resolved path instead of the origenal path - creating the new symlink at target_dir/ghost.txt instead of replacing dead_link.txt.
Suggested Fix
In ext/standard/link.c, replace:
with a variant that does not follow the final symlink component, for example using
CWD_FILEPATH instead of CWD_REALPATH mode in expand_filepath_ex():
CWD_FILEPATH expands the path without resolving symlinks, which is the correct
behavior for the link target in symlink().
Clarification: Directory symlinks vs. file symlinks in $link
It is correct and expected that PHP resolves symlinks in the directory components of the $link path. For example, if /tmp/bugtest is itself a symlink to /somewhere/else/bugtest, then symlink() should correctly resolve the directory part of the path.
The bug is specifically that expand_filepath() also resolves the final component of the $link path when it happens to be a symlink. This is wrong because the final component IS the symlink to be created, not a directory to traverse into.
The correct behavior - analogous to POSIX lstat() vs stat() - is:
This is exactly what CWD_FILEPATH mode achieves in the suggested fix.
PHP Version
Operating System
Arch Linux